#272 fix:search spins forever in case of error (#302)

This commit is contained in:
lenhanphung
2025-11-14 19:28:05 +07:00
committed by GitHub
parent 1bc9b0c160
commit e8374cc2a1
5 changed files with 278 additions and 122 deletions
+89
View File
@@ -127,4 +127,93 @@ describe("PeopleSearch", () => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); 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<User[]>((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;
});
});
}); });
+173 -118
View File
@@ -5,13 +5,14 @@ import ListItem from "@mui/material/ListItem";
import ListItemAvatar from "@mui/material/ListItemAvatar"; import ListItemAvatar from "@mui/material/ListItemAvatar";
import ListItemText from "@mui/material/ListItemText"; import ListItemText from "@mui/material/ListItemText";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { searchUsers } from "../../features/User/userAPI"; import { searchUsers } from "../../features/User/userAPI";
import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined"; import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined";
import Chip from "@mui/material/Chip"; import Chip from "@mui/material/Chip";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { getAccessiblePair } from "../Calendar/utils/calendarColorsUtils"; import { getAccessiblePair } from "../Calendar/utils/calendarColorsUtils";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
import { SnackbarAlert } from "../Loading/SnackBarAlert";
export interface User { export interface User {
email: string; email: string;
@@ -40,137 +41,191 @@ export function PeopleSearch({
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [options, setOptions] = useState<User[]>([]); const [options, setOptions] = useState<User[]>([]);
const [hasSearched, setHasSearched] = useState(false);
const isValidEmail = (email: string) => const isValidEmail = (email: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const [error, setError] = useState<string | null>(null); const [inputError, setInputError] = useState<string | null>(null);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState("");
const theme = useTheme(); const theme = useTheme();
useEffect(() => { useEffect(() => {
let cancelled = false;
const delayDebounceFn = setTimeout(async () => { const delayDebounceFn = setTimeout(async () => {
if (query) { if (!query.trim()) {
if (!cancelled) {
setOptions([]);
setLoading(false);
setHasSearched(false);
}
return;
}
if (!cancelled) {
setLoading(true); setLoading(true);
setHasSearched(false);
}
try {
const res = await searchUsers(query, objectTypes); const res = await searchUsers(query, objectTypes);
setOptions(res); if (!cancelled) {
setLoading(false); setOptions(res);
setHasSearched(true);
}
} catch (error: any) {
if (!cancelled) {
setHasSearched(false);
setSnackbarMessage(t("peopleSearch.searchError"));
setSnackbarOpen(true);
}
} finally {
if (!cancelled) {
setLoading(false);
}
} }
}, 300); }, 300);
return () => clearTimeout(delayDebounceFn); return () => {
}, [query, objectTypes]); cancelled = true;
clearTimeout(delayDebounceFn);
};
}, [objectTypes, query, t]);
return ( return (
<Autocomplete <>
freeSolo={freeSolo} <Autocomplete
multiple freeSolo={freeSolo}
options={options} multiple
autoComplete={false} options={options}
open={!!query} autoComplete={false}
disabled={disabled} open={!!query && (loading || hasSearched)}
loading={loading} disabled={disabled}
filterOptions={(x) => x} loading={loading}
fullWidth filterOptions={(x) => x}
getOptionLabel={(option) => { fullWidth
if (typeof option === "object") { noOptionsText={t("peopleSearch.noResults")}
return option.displayName || option.email; loadingText={t("peopleSearch.loading")}
} else { getOptionLabel={(option) => {
return option; if (typeof option === "object") {
} return option.displayName || option.email;
}} } else {
filterSelectedOptions return option;
value={selectedUsers} }
onInputChange={(event, value) => setQuery(value)} }}
onChange={(event, value) => { filterSelectedOptions
const last = value[value.length - 1]; value={selectedUsers}
if (typeof last === "string" && !isValidEmail(last)) { onInputChange={(event, value) => setQuery(value)}
setError(t("peopleSearch.invalidEmail", { email: last })); onChange={(event, value) => {
return; const last = value[value.length - 1];
} if (typeof last === "string" && !isValidEmail(last)) {
setError(null); const invalidEmailMessage = t("peopleSearch.invalidEmail").replace(
const mapped = value.map((v: any) => "%{email}",
typeof v === "string" ? { email: v } : v last
); );
onChange(event, mapped); setInputError(invalidEmailMessage);
}} return;
renderInput={(params) => ( }
<TextField setInputError(null);
{...params} const mapped = value.map((v: any) =>
error={!!error} typeof v === "string" ? { email: v } : v
helperText={error}
placeholder={t("peopleSearch.placeholder")}
label={t("peopleSearch.label")}
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter" && onToggleEventPreview) {
e.preventDefault();
onToggleEventPreview();
}
}}
slotProps={{
input: {
...params.InputProps,
autoComplete: "off",
startAdornment: (
<>
<PeopleOutlineOutlinedIcon
style={{ marginRight: 8, color: "rgba(0, 0, 0, 0.54)" }}
/>
{params.InputProps.startAdornment}
</>
),
endAdornment: (
<>
{loading ? (
<CircularProgress color="inherit" size={20} />
) : 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 (
<ListItem key={key + option?.email} {...otherProps} disableGutters>
<ListItemAvatar>
<Avatar src={option.avatarUrl} alt={option.displayName} />
</ListItemAvatar>
<ListItemText
primary={option.displayName}
secondary={option.email}
/>
</ListItem>
);
}}
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 (
<Chip
{...getTagProps({ index })}
key={label}
style={{
backgroundColor: chipColor,
color: textColor,
}}
label={label}
/>
); );
}) onChange(event, mapped);
} }}
/> renderInput={(params) => (
<TextField
{...params}
error={!!inputError}
helperText={inputError}
placeholder={t("peopleSearch.placeholder")}
label={t("peopleSearch.label")}
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter" && onToggleEventPreview) {
e.preventDefault();
onToggleEventPreview();
}
}}
slotProps={{
input: {
...params.InputProps,
autoComplete: "off",
startAdornment: (
<>
<PeopleOutlineOutlinedIcon
style={{ marginRight: 8, color: "rgba(0, 0, 0, 0.54)" }}
/>
{params.InputProps.startAdornment}
</>
),
endAdornment: (
<>
{loading ? (
<CircularProgress color="inherit" size={20} />
) : 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 (
<ListItem key={key + option?.email} {...otherProps} disableGutters>
<ListItemAvatar>
<Avatar src={option.avatarUrl} alt={option.displayName} />
</ListItemAvatar>
<ListItemText
primary={option.displayName}
secondary={option.email}
/>
</ListItem>
);
}}
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 (
<Chip
{...getTagProps({ index })}
key={label}
style={{
backgroundColor: chipColor,
color: textColor,
}}
label={label}
/>
);
})
}
/>
<SnackbarAlert
open={snackbarOpen}
setOpen={(open: boolean) => {
setSnackbarOpen(open);
if (!open) {
setSnackbarMessage("");
}
}}
message={snackbarMessage}
severity="error"
/>
</>
); );
} }
+8 -2
View File
@@ -1,14 +1,16 @@
import Alert from "@mui/material/Alert"; import Alert, { AlertColor } from "@mui/material/Alert";
import Snackbar from "@mui/material/Snackbar"; import Snackbar from "@mui/material/Snackbar";
export function SnackbarAlert({ export function SnackbarAlert({
open, open,
setOpen, setOpen,
message, message,
severity = "success",
}: { }: {
open: boolean; open: boolean;
setOpen: Function; setOpen: Function;
message: string; message: string;
severity?: AlertColor;
}) { }) {
return ( return (
<Snackbar <Snackbar
@@ -17,7 +19,11 @@ export function SnackbarAlert({
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }} anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
> >
<Alert onClose={() => setOpen(false)} sx={{ width: "100%" }}> <Alert
severity={severity}
onClose={() => setOpen(false)}
sx={{ width: "100%" }}
>
{message} {message}
</Alert> </Alert>
</Snackbar> </Snackbar>
+4 -1
View File
@@ -154,7 +154,10 @@
"peopleSearch": { "peopleSearch": {
"label": "Start typing a name or email", "label": "Start typing a name or email",
"placeholder": "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": { "error": {
"title": "Something went wrong", "title": "Something went wrong",
+4 -1
View File
@@ -154,7 +154,10 @@
"peopleSearch": { "peopleSearch": {
"label": "Commencez à saisir un nom ou un e-mail", "label": "Commencez à saisir un nom ou un e-mail",
"placeholder": "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": { "error": {
"title": "Une erreur s'est produite", "title": "Une erreur s'est produite",