@@ -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<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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<User[]>([]);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
|
||||
const isValidEmail = (email: string) =>
|
||||
/^[^\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();
|
||||
|
||||
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 (
|
||||
<Autocomplete
|
||||
freeSolo={freeSolo}
|
||||
multiple
|
||||
options={options}
|
||||
autoComplete={false}
|
||||
open={!!query}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
filterOptions={(x) => 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) => (
|
||||
<TextField
|
||||
{...params}
|
||||
error={!!error}
|
||||
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}
|
||||
/>
|
||||
<>
|
||||
<Autocomplete
|
||||
freeSolo={freeSolo}
|
||||
multiple
|
||||
options={options}
|
||||
autoComplete={false}
|
||||
open={!!query && (loading || hasSearched)}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
filterOptions={(x) => 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) => (
|
||||
<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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Snackbar
|
||||
@@ -17,7 +19,11 @@ export function SnackbarAlert({
|
||||
onClose={() => setOpen(false)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
>
|
||||
<Alert onClose={() => setOpen(false)} sx={{ width: "100%" }}>
|
||||
<Alert
|
||||
severity={severity}
|
||||
onClose={() => setOpen(false)}
|
||||
sx={{ width: "100%" }}
|
||||
>
|
||||
{message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user