diff --git a/__test__/components/PeopleSearch.test.tsx b/__test__/components/PeopleSearch.test.tsx index ba8a934..3d5c7b5 100644 --- a/__test__/components/PeopleSearch.test.tsx +++ b/__test__/components/PeopleSearch.test.tsx @@ -127,4 +127,93 @@ describe("PeopleSearch", () => { expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); }); }); + + it("shows 'No results' when search succeeds but returns empty array", async () => { + mockedSearchUsers.mockResolvedValueOnce([]); + setup(); + + const input = screen.getByRole("combobox"); + await userEvent.type(input, "Test"); + + await act(async () => { + jest.advanceTimersByTime(300); + await Promise.resolve(); + await Promise.resolve(); + }); + + const noResults = await screen.findByText( + "peopleSearch.noResults", + {}, + { timeout: 5000 } + ); + expect(noResults).toBeInTheDocument(); + }); + + it("does not clear options when search fails and shows error snackbar", async () => { + mockedSearchUsers.mockResolvedValueOnce([baseUser]); + setup(); + + const input = screen.getByRole("combobox"); + await userEvent.type(input, "Test"); + await act(async () => { + jest.advanceTimersByTime(300); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByText("Test User")).toBeInTheDocument(); + }); + + mockedSearchUsers.mockRejectedValueOnce(new Error("Network error")); + await userEvent.clear(input); + await userEvent.type(input, "Error"); + await act(async () => { + jest.advanceTimersByTime(300); + await Promise.resolve(); + }); + + const errorMessage = await screen.findByText("peopleSearch.searchError"); + expect(errorMessage).toBeInTheDocument(); + + expect(screen.queryByText("Test User")).not.toBeInTheDocument(); + + mockedSearchUsers.mockResolvedValueOnce([baseUser]); + await userEvent.clear(input); + await userEvent.type(input, "Test"); + await act(async () => { + jest.advanceTimersByTime(300); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByText("Test User")).toBeInTheDocument(); + }); + }); + + it("shows loading text when searching", async () => { + let resolveSearch: (value: User[]) => void; + const searchPromise = new Promise((resolve) => { + resolveSearch = resolve; + }); + mockedSearchUsers.mockReturnValueOnce(searchPromise); + setup(); + + const input = screen.getByRole("combobox"); + await userEvent.type(input, "Test"); + await act(async () => { + jest.advanceTimersByTime(300); + }); + + const loadingText = await screen.findByText( + "peopleSearch.loading", + {}, + { timeout: 5000 } + ); + expect(loadingText).toBeInTheDocument(); + + await act(async () => { + resolveSearch!([baseUser]); + await searchPromise; + }); + }); }); diff --git a/src/components/Attendees/PeopleSearch.tsx b/src/components/Attendees/PeopleSearch.tsx index 3cb1ff7..dc3bf26 100644 --- a/src/components/Attendees/PeopleSearch.tsx +++ b/src/components/Attendees/PeopleSearch.tsx @@ -5,13 +5,14 @@ import ListItem from "@mui/material/ListItem"; import ListItemAvatar from "@mui/material/ListItemAvatar"; import ListItemText from "@mui/material/ListItemText"; import TextField from "@mui/material/TextField"; -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; import { searchUsers } from "../../features/User/userAPI"; import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined"; import Chip from "@mui/material/Chip"; import { useTheme } from "@mui/material/styles"; import { getAccessiblePair } from "../Calendar/utils/calendarColorsUtils"; import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; +import { SnackbarAlert } from "../Loading/SnackBarAlert"; export interface User { email: string; @@ -40,137 +41,191 @@ export function PeopleSearch({ const [query, setQuery] = useState(""); const [loading, setLoading] = useState(false); const [options, setOptions] = useState([]); + const [hasSearched, setHasSearched] = useState(false); const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); - const [error, setError] = useState(null); + const [inputError, setInputError] = useState(null); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const [snackbarMessage, setSnackbarMessage] = useState(""); const theme = useTheme(); useEffect(() => { + let cancelled = false; + const delayDebounceFn = setTimeout(async () => { - if (query) { + if (!query.trim()) { + if (!cancelled) { + setOptions([]); + setLoading(false); + setHasSearched(false); + } + return; + } + + if (!cancelled) { setLoading(true); + setHasSearched(false); + } + + try { const res = await searchUsers(query, objectTypes); - setOptions(res); - setLoading(false); + if (!cancelled) { + setOptions(res); + setHasSearched(true); + } + } catch (error: any) { + if (!cancelled) { + setHasSearched(false); + setSnackbarMessage(t("peopleSearch.searchError")); + setSnackbarOpen(true); + } + } finally { + if (!cancelled) { + setLoading(false); + } } }, 300); - return () => clearTimeout(delayDebounceFn); - }, [query, objectTypes]); + return () => { + cancelled = true; + clearTimeout(delayDebounceFn); + }; + }, [objectTypes, query, t]); return ( - x} - fullWidth - getOptionLabel={(option) => { - if (typeof option === "object") { - return option.displayName || option.email; - } else { - return option; - } - }} - filterSelectedOptions - value={selectedUsers} - onInputChange={(event, value) => setQuery(value)} - onChange={(event, value) => { - const last = value[value.length - 1]; - if (typeof last === "string" && !isValidEmail(last)) { - setError(t("peopleSearch.invalidEmail", { email: last })); - return; - } - setError(null); - const mapped = value.map((v: any) => - typeof v === "string" ? { email: v } : v - ); - onChange(event, mapped); - }} - renderInput={(params) => ( - { - if (e.key === "Enter" && onToggleEventPreview) { - e.preventDefault(); - onToggleEventPreview(); - } - }} - slotProps={{ - input: { - ...params.InputProps, - autoComplete: "off", - startAdornment: ( - <> - - {params.InputProps.startAdornment} - - ), - endAdornment: ( - <> - {loading ? ( - - ) : null} - {params.InputProps.endAdornment} - - ), - }, - }} - inputProps={{ - ...params.inputProps, - autoComplete: "off", - }} - /> - )} - renderOption={(props, option) => { - if (selectedUsers.find((u) => u.email === option.email)) return null; - const { key, ...otherProps } = props as any; - return ( - - - - - - - ); - }} - renderValue={(value, getTagProps) => - value.map((option, index) => { - const isString = typeof option === "string"; - const label = isString ? option : option.displayName || option.email; - const chipColor = isString - ? theme.palette.grey[300] - : (option.color?.light ?? theme.palette.grey[300]); - const textColor = getAccessiblePair(chipColor, theme); - - return ( - + <> + x} + fullWidth + noOptionsText={t("peopleSearch.noResults")} + loadingText={t("peopleSearch.loading")} + getOptionLabel={(option) => { + if (typeof option === "object") { + return option.displayName || option.email; + } else { + return option; + } + }} + filterSelectedOptions + value={selectedUsers} + onInputChange={(event, value) => setQuery(value)} + onChange={(event, value) => { + const last = value[value.length - 1]; + if (typeof last === "string" && !isValidEmail(last)) { + const invalidEmailMessage = t("peopleSearch.invalidEmail").replace( + "%{email}", + last + ); + setInputError(invalidEmailMessage); + return; + } + setInputError(null); + const mapped = value.map((v: any) => + typeof v === "string" ? { email: v } : v ); - }) - } - /> + onChange(event, mapped); + }} + renderInput={(params) => ( + { + if (e.key === "Enter" && onToggleEventPreview) { + e.preventDefault(); + onToggleEventPreview(); + } + }} + slotProps={{ + input: { + ...params.InputProps, + autoComplete: "off", + startAdornment: ( + <> + + {params.InputProps.startAdornment} + + ), + endAdornment: ( + <> + {loading ? ( + + ) : null} + {params.InputProps.endAdornment} + + ), + }, + }} + inputProps={{ + ...params.inputProps, + autoComplete: "off", + }} + /> + )} + renderOption={(props, option) => { + if (selectedUsers.find((u) => u.email === option.email)) return null; + const { key, ...otherProps } = props as any; + return ( + + + + + + + ); + }} + renderValue={(value, getTagProps) => + value.map((option, index) => { + const isString = typeof option === "string"; + const label = isString + ? option + : option.displayName || option.email; + const chipColor = isString + ? theme.palette.grey[300] + : (option.color?.light ?? theme.palette.grey[300]); + const textColor = getAccessiblePair(chipColor, theme); + + return ( + + ); + }) + } + /> + { + setSnackbarOpen(open); + if (!open) { + setSnackbarMessage(""); + } + }} + message={snackbarMessage} + severity="error" + /> + ); } diff --git a/src/components/Loading/SnackBarAlert.tsx b/src/components/Loading/SnackBarAlert.tsx index c5dbd08..ab10fdf 100644 --- a/src/components/Loading/SnackBarAlert.tsx +++ b/src/components/Loading/SnackBarAlert.tsx @@ -1,14 +1,16 @@ -import Alert from "@mui/material/Alert"; +import Alert, { AlertColor } from "@mui/material/Alert"; import Snackbar from "@mui/material/Snackbar"; export function SnackbarAlert({ open, setOpen, message, + severity = "success", }: { open: boolean; setOpen: Function; message: string; + severity?: AlertColor; }) { return ( setOpen(false)} anchorOrigin={{ vertical: "bottom", horizontal: "center" }} > - setOpen(false)} sx={{ width: "100%" }}> + setOpen(false)} + sx={{ width: "100%" }} + > {message} diff --git a/src/locales/en.json b/src/locales/en.json index 90eae1a..dd18300 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -154,7 +154,10 @@ "peopleSearch": { "label": "Start typing a name or email", "placeholder": "Start typing a name or email", - "invalidEmail": "%{email} is not a valid email address" + "invalidEmail": "%{email} is not a valid email address", + "searchError": "Unable to fetch contacts right now. Please try again.", + "noResults": "No results", + "loading": "Loading..." }, "error": { "title": "Something went wrong", diff --git a/src/locales/fr.json b/src/locales/fr.json index 1a8fcaf..fcf1970 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -154,7 +154,10 @@ "peopleSearch": { "label": "Commencez à saisir un nom ou un e-mail", "placeholder": "Commencez à saisir un nom ou un e-mail", - "invalidEmail": "%{email} n'est pas une adresse e-mail valide" + "invalidEmail": "%{email} n'est pas une adresse e-mail valide", + "searchError": "Impossible de récupérer les contacts pour le moment. Veuillez réessayer.", + "noResults": "Aucun résultat", + "loading": "Chargement..." }, "error": { "title": "Une erreur s'est produite",