[#523] changed search bar to work like an autocomplete (#406)

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2025-12-15 22:10:17 +01:00
committed by GitHub
parent f413bd832b
commit c81153a119
6 changed files with 424 additions and 218 deletions
@@ -145,7 +145,7 @@ describe("EventSearchBar", () => {
fireEvent.click(searchButton);
const searchInput = screen.getByPlaceholderText("common.search");
fireEvent.blur(searchInput);
fireEvent.mouseDown(document.body);
expect(searchInput).not.toBeInTheDocument();
});
@@ -220,7 +220,7 @@ describe("EventSearchBar", () => {
await waitFor(() => {
expect(searchSpy).toHaveBeenCalledWith({
filters: {
keywords: "test",
keywords: "",
organizers: [],
attendees: [],
searchIn: ["user1/cal1"],
@@ -263,4 +263,82 @@ describe("EventSearchBar", () => {
expect(searchSpy).not.toHaveBeenCalled();
});
});
it("keeps search bar expanded when popover opens", () => {
renderWithProviders(<SearchBar />, preloadedState);
fireEvent.click(screen.getByRole("button"));
const tuneBtn = screen
.getAllByRole("button")
.find((b) => b.querySelector('[data-testid="TuneIcon"]'));
fireEvent.click(tuneBtn!);
expect(screen.getByPlaceholderText("common.search")).toBeInTheDocument();
});
it("does NOT collapse search when clicking inside popover", () => {
renderWithProviders(<SearchBar />, preloadedState);
fireEvent.click(screen.getByRole("button"));
const tuneBtn = screen
.getAllByRole("button")
.find((b) => b.querySelector('[data-testid="TuneIcon"]'));
fireEvent.click(tuneBtn!);
fireEvent.mouseDown(screen.getByText("search.searchIn"));
expect(screen.getByPlaceholderText("common.search")).toBeInTheDocument();
});
it("clicking Cancel closes popover and search collapses if empty", async () => {
renderWithProviders(<SearchBar />, preloadedState);
fireEvent.click(screen.getByRole("button"));
const tuneBtn = screen
.getAllByRole("button")
.find((b) => b.querySelector('[data-testid="TuneIcon"]'));
fireEvent.click(tuneBtn!);
fireEvent.click(screen.getByText("common.cancel"));
await waitFor(() =>
expect(screen.queryByText("search.searchIn")).not.toBeInTheDocument()
);
expect(screen.queryByPlaceholderText("common.search")).toBeNull();
});
it("does not collapse when selecting a filter value", async () => {
renderWithProviders(<SearchBar />, preloadedState);
fireEvent.click(screen.getByRole("button"));
const tuneBtn = screen
.getAllByRole("button")
.find((b) => b.querySelector('[data-testid="TuneIcon"]'));
fireEvent.click(tuneBtn!);
fireEvent.mouseDown(screen.getByText("search.searchIn")); // simulate interaction inside menu
expect(screen.getByPlaceholderText("common.search")).toBeInTheDocument();
});
it("keeps input value after popover closes if not empty", () => {
renderWithProviders(<SearchBar />, preloadedState);
fireEvent.click(screen.getByRole("button"));
userEvent.type(screen.getByPlaceholderText("common.search"), "hello");
const tuneBtn = screen
.getAllByRole("button")
.find((b) => b.querySelector('[data-testid="TuneIcon"]'));
fireEvent.click(tuneBtn!);
fireEvent.click(document.body);
const inputAfter = screen.getByPlaceholderText("common.search");
expect(inputAfter).toHaveValue("hello");
});
});
+82 -95
View File
@@ -7,7 +7,7 @@ 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 { useEffect, useState } from "react";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { searchUsers } from "../../features/User/userAPI";
import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined";
import Chip from "@mui/material/Chip";
@@ -19,8 +19,8 @@ import { SnackbarAlert } from "../Loading/SnackBarAlert";
export interface User {
email: string;
displayName: string;
avatarUrl: string;
openpaasId: string;
avatarUrl?: string;
openpaasId?: string;
color?: Record<string, string>;
}
@@ -41,9 +41,10 @@ export function PeopleSearch({
onToggleEventPreview,
placeholder,
inputSlot,
customRenderInput,
}: {
selectedUsers: User[];
onChange: Function;
onChange: (event: any, users: User[]) => void;
objectTypes: string[];
disabled?: boolean;
freeSolo?: boolean;
@@ -52,6 +53,11 @@ export function PeopleSearch({
inputSlot?: (
params: ExtendedAutocompleteRenderInputParams
) => React.ReactNode;
customRenderInput?: (
params: AutocompleteRenderInputParams,
query: string,
setQuery: (value: string) => void
) => ReactNode;
}) {
const { t } = useI18n();
const [query, setQuery] = useState("");
@@ -93,7 +99,7 @@ export function PeopleSearch({
setOptions(res);
setHasSearched(true);
}
} catch (error: any) {
} catch {
if (!cancelled) {
setHasSearched(false);
setSnackbarMessage(t("peopleSearch.searchError"));
@@ -112,6 +118,56 @@ export function PeopleSearch({
};
}, [objectTypes, query, t]);
const defaultRenderInput = useCallback(
(params: AutocompleteRenderInputParams) => (
<>
<label htmlFor={params.id} className="visually-hidden">
{t("peopleSearch.label")}
</label>
<TextField
{...params}
error={!!inputError}
helperText={inputError}
placeholder={searchPlaceholder}
label=""
inputProps={{
...params.inputProps,
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}
</>
),
},
}}
/>
</>
),
[inputError, t, onToggleEventPreview, loading]
);
return (
<>
<Autocomplete
@@ -122,7 +178,11 @@ export function PeopleSearch({
autoComplete={false}
clearOnBlur={false}
blurOnSelect={true}
open={isOpen && !!query && (loading || hasSearched)}
open={
customRenderInput
? isOpen && !!query && (loading || options.length > 0)
: isOpen && !!query && (loading || hasSearched)
}
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
disabled={disabled}
@@ -138,13 +198,18 @@ export function PeopleSearch({
return option;
}
}}
sx={{
"& .MuiAutocomplete-inputRoot": {
py: 0,
},
}}
filterSelectedOptions
value={selectedUsers}
inputValue={query}
onInputChange={(event, value) => setQuery(value)}
onInputChange={(_event, value) => setQuery(value)}
onChange={(event, value) => {
const last = value[value.length - 1];
if (typeof last === "string" && !isValidEmail(last)) {
if (typeof last === "string" && !isValidEmail(last.trim())) {
const invalidEmailMessage = t("peopleSearch.invalidEmail").replace(
"%{email}",
last
@@ -153,96 +218,18 @@ export function PeopleSearch({
return;
}
setInputError(null);
const mapped = value.map((v: any) =>
typeof v === "string" ? { email: v } : v
const mapped = value.map((v: string | User) =>
typeof v === "string"
? { email: v.trim(), displayName: v.trim() }
: v
);
onChange(event, mapped);
}}
renderInput={(params) => {
const inputProps = {
...params.InputProps,
startAdornment: (
<>
<PeopleOutlineOutlinedIcon
sx={{ mr: 1, color: "action.active" }}
/>
{params.InputProps.startAdornment}
</>
),
endAdornment: (
<>
{loading ? (
<CircularProgress color="inherit" size={20} />
) : null}
{params.InputProps.endAdornment}
</>
),
};
const enhancedParamsWithInputProps = {
...params,
InputProps: inputProps,
inputProps: {
...params.inputProps,
autoComplete: "off",
},
};
const { InputProps, ...enhancedParams } =
enhancedParamsWithInputProps;
const handleEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && onToggleEventPreview) {
e.preventDefault();
onToggleEventPreview();
}
};
const defaultTextFieldProps = {
error: !!inputError,
helperText: inputError,
placeholder: searchPlaceholder,
label: "",
onKeyDown: handleEnterKey,
slotProps: {
input: {
...inputProps,
},
},
};
if (inputSlot) {
return (
<>
<label htmlFor={params.id} className="visually-hidden">
{t("peopleSearch.label")}
</label>
{inputSlot({
...enhancedParamsWithInputProps,
error: !!inputError,
helperText: inputError,
placeholder: searchPlaceholder,
label: "",
onKeyDown: handleEnterKey,
})}
</>
);
}
return (
<>
<label htmlFor={params.id} className="visually-hidden">
{t("peopleSearch.label")}
</label>
<TextField
{...enhancedParams}
{...defaultTextFieldProps}
InputProps={inputProps}
size="medium"
/>
</>
);
}}
renderInput={(params) =>
customRenderInput
? customRenderInput(params, query, setQuery)
: defaultRenderInput(params)
}
renderOption={(props, option) => {
if (selectedUsers.find((u) => u.email === option.email)) return null;
const { key, ...otherProps } = props as any;
+16 -13
View File
@@ -282,19 +282,21 @@ export default function CalendarSearch({
const cals = await Promise.all(
value.map(async (user: User) => {
const cals = (await getCalendars(
user.openpaasId,
"sharedPublic=true&"
)) as Record<string, any>;
return cals._embedded?.["dav:calendar"]
? cals._embedded["dav:calendar"].map(
(cal: Record<string, any>) => ({ cal, owner: user })
)
: { cal: undefined, owner: user };
if (user?.openpaasId) {
const cals = (await getCalendars(
user.openpaasId,
"sharedPublic=true&"
)) as Record<string, any>;
return cals._embedded?.["dav:calendar"]
? cals._embedded["dav:calendar"].map(
(cal: Record<string, any>) => ({ cal, owner: user })
)
: { cal: undefined, owner: user };
}
return null;
})
);
setSelectedCalendars(cals.flat());
setSelectedCalendars(cals.flat().filter(Boolean));
}}
/>
@@ -302,16 +304,17 @@ export default function CalendarSearch({
calendars={calendars}
selectedCal={selectedCal}
onRemove={(cal) => {
if (!cal.cal?._links?.self?.href) return;
setSelectedCalendars((prev) =>
prev.filter(
(c) => c.cal._links.self.href !== cal.cal._links.self.href
(c) => c.cal?._links?.self?.href !== cal.cal._links.self.href
)
);
if (
!selectedCal.find(
(c) =>
cal.owner.email === c.owner.email &&
c.cal._links.self.href !== cal.cal._links.self.href
c.cal?._links?.self?.href !== cal.cal._links.self.href
)
) {
setSelectedUsers((prev) =>
@@ -30,7 +30,7 @@ export async function handleRSVP(
const newEvent = {
...event,
attendee: event.attendee?.map((a) =>
a.cal_address === user.userData.email ? { ...a, partstat: rsvp } : a
a.cal_address === user.userData?.email ? { ...a, partstat: rsvp } : a
),
};
if (typeOfAction === "solo") {
@@ -39,7 +39,7 @@ export async function handleRSVP(
const calendarRange = getCalendarRange(new Date(event.start));
// Update PARTSTAT on ALL VEVENTs (master + exceptions)
await updateSeriesPartstat(event, user.userData.email, rsvp);
await updateSeriesPartstat(event, user.userData?.email, rsvp);
if (calendars) {
await refreshCalendars(dispatch, calendars, calendarRange);
+240 -102
View File
@@ -15,7 +15,8 @@ import {
TextField,
Typography,
} from "@mui/material";
import { useRef, useState } from "react";
import { type AutocompleteRenderInputParams } from "@mui/material/Autocomplete";
import { useRef, useState, useEffect } from "react";
import HighlightOffIcon from "@mui/icons-material/HighlightOff";
import SearchIcon from "@mui/icons-material/Search";
import TuneIcon from "@mui/icons-material/Tune";
@@ -26,6 +27,7 @@ import { setView } from "../../features/Settings/SettingsSlice";
import { userAttendee } from "../../features/User/userDataTypes";
import UserSearch from "../Attendees/AttendeeSearch";
import { CalendarItemList } from "../Calendar/CalendarItemList";
import { PeopleSearch, User } from "../Attendees/PeopleSearch";
export default function SearchBar() {
const { t } = useI18n();
@@ -33,15 +35,16 @@ export default function SearchBar() {
const calendars = Object.values(
useAppSelector((state) => state.calendars.list)
);
const userId = useAppSelector((state) => state.user.userData.openpaasId);
const personnalCalendars = calendars.filter(
(c) => c.id.split("/")[0] === userId
);
const sharedCalendars = calendars.filter(
(c) => c.id.split("/")[0] !== userId
);
const userId = useAppSelector((state) => state.user.userData?.openpaasId);
const personnalCalendars = userId
? calendars.filter((c) => c.id.split("/")[0] === userId)
: [];
const sharedCalendars = userId
? calendars.filter((c) => c.id.split("/")[0] !== userId)
: calendars;
const [search, setSearch] = useState("");
const [selectedContacts, setSelectedContacts] = useState<User[]>([]);
const [extended, setExtended] = useState(false);
const [filters, setFilters] = useState({
@@ -63,28 +66,37 @@ export default function SearchBar() {
width: "55vw",
},
};
const searchBoxRef = useRef<HTMLElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const shouldCollapseRef = useRef(false);
type FilterField = "searchIn" | "keywords" | "organizers" | "attendees";
const handleFilterChange = (
field: string,
field: FilterField,
value: string | userAttendee[]
) => {
setFilters((prev) => ({ ...prev, [field]: value }));
if (field === "organizers") {
setSelectedContacts(
(value as userAttendee[]).map((a: userAttendee) => ({
displayName: a.cn ?? a.cal_address,
email: a.cal_address || "",
}))
);
}
};
const handleClearFilters = () => {
setFilters({
searchIn: "my-calendars",
keywords: "",
organizers: [] as userAttendee[],
attendees: [] as userAttendee[],
});
setAnchorEl(null);
setExtended(false);
};
const handleSearch = async () => {
const trimmedSearch = search.trim();
function buildQuery(
searchQuery: string,
filters: {
searchIn: string;
keywords: string;
organizers: userAttendee[];
attendees: userAttendee[];
}
) {
const trimmedSearch = searchQuery.trim();
const trimmedKeywords = filters.keywords.trim();
// Block search if all search criteria are empty
@@ -116,28 +128,91 @@ export default function SearchBar() {
attendees: filters.attendees.map((u) => u.cal_address),
searchIn: searchInCalendars,
};
return {
search: trimmedSearch,
filters: cleanedFilters,
};
}
dispatch(
searchEventsAsync({
search: trimmedSearch,
filters: cleanedFilters,
})
);
const handleClearFilters = () => {
setFilters({
searchIn: "my-calendars",
keywords: "",
organizers: [] as userAttendee[],
attendees: [] as userAttendee[],
});
setAnchorEl(null);
};
const handleContactSelect = (_event: any, contacts: User[]) => {
setSelectedContacts(contacts);
setSearch("");
if (contacts.length > 0) {
handleSearch("", {
...filters,
organizers: contacts.map((c) => ({
cal_address: c.email || c.displayName || "",
cutype: "INDIVIDUAL",
cn: c.displayName || c.email,
role: "Participant",
rsvp: "TRUE",
partstat: "",
})),
});
}
};
const handleSearch = async (
searchQuery: string,
filters: {
searchIn: string;
keywords: string;
organizers: userAttendee[];
attendees: userAttendee[];
}
) => {
const cleanedQuery = buildQuery(searchQuery, filters);
if (cleanedQuery) {
dispatch(searchEventsAsync(cleanedQuery));
}
dispatch(setView("search"));
setAnchorEl(null);
};
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
const target = event.target as Node;
if (filterOpen) {
return;
}
if (
containerRef.current?.contains(target) ||
inputRef.current?.contains(target) ||
(target as HTMLElement).closest(".MuiAutocomplete-popper")
) {
return;
}
if (!search.trim() && selectedContacts.length === 0) {
setExtended(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [filterOpen, search, selectedContacts]);
return (
<>
<Box
ref={searchBoxRef}
ref={containerRef}
sx={{
margin: "0 auto",
height: "44px",
position: "relative",
width: extended ? searchWidth : "auto",
transition: "width 0.25s ease-out",
}}
>
@@ -148,76 +223,114 @@ export default function SearchBar() {
)}
{extended && (
<TextField
fullWidth
autoFocus
placeholder={t("common.search")}
value={search}
onBlur={(e) => {
const next = e.relatedTarget as HTMLElement | null;
if (
next instanceof Node &&
searchBoxRef.current?.contains(next)
) {
return;
}
if (!search.trim()) {
setExtended(false);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
onChange={(e) => {
setSearch(e.target.value);
handleFilterChange("keywords", e.target.value);
}}
variant="outlined"
sx={{
borderRadius: "999px",
"& .MuiOutlinedInput-root": {
borderRadius: "999px",
},
"& .MuiInputBase-input": { padding: "12px 10px" },
animation: "scaleIn 0.25s ease-out",
"@keyframes scaleIn": {
from: { transform: "scaleX(0)", opacity: 0 },
to: { transform: "scaleX(1)", opacity: 1 },
},
transformOrigin: "right",
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{ color: "#605D62" }} />
</InputAdornment>
),
endAdornment: (
<>
<InputAdornment position="end">
<IconButton
onClick={(e) => setAnchorEl(searchBoxRef.current)}
>
<TuneIcon />
</IconButton>
</InputAdornment>
{search && (
<InputAdornment position="end">
<IconButton
onClick={() => {
setSearch("");
handleFilterChange("keywords", "");
}}
>
<HighlightOffIcon />
</IconButton>
</InputAdornment>
)}
</>
),
<PeopleSearch
selectedUsers={selectedContacts}
onChange={(event, users) => {
handleContactSelect(event, users);
}}
objectTypes={["user", "contact"]}
onToggleEventPreview={() => {}}
customRenderInput={(
params: AutocompleteRenderInputParams,
query: string,
setQuery: (value: string) => void
) => (
<TextField
{...params}
fullWidth
autoFocus
placeholder={t("common.search")}
value={query}
inputRef={(el) => {
inputRef.current = el;
const ref = params.InputProps.ref;
if (typeof ref === "function") {
ref(el);
} else if (ref && "current" in ref) {
(
ref as React.MutableRefObject<HTMLInputElement | null>
).current = el;
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearch(query, filters);
}
}}
onChange={(e) => {
const value = e.target.value;
setQuery(value);
setSearch(value);
}}
variant="outlined"
sx={{
borderRadius: "999px",
"& .MuiInputBase-input": { padding: "12px 10px" },
animation: "scaleIn 0.25s ease-out",
"@keyframes scaleIn": {
from: { transform: "scaleX(0)", opacity: 0 },
to: { transform: "scaleX(1)", opacity: 1 },
},
transformOrigin: "right",
"& .MuiOutlinedInput-root": {
borderRadius: "999px",
height: 40,
padding: "0 10px",
},
}}
InputProps={{
...params.InputProps,
startAdornment: (
<>
<InputAdornment position="start">
<SearchIcon sx={{ color: "#605D62" }} />
</InputAdornment>
{params.InputProps.startAdornment}
</>
),
endAdornment: (
<>
{params.InputProps.endAdornment}
<InputAdornment position="end">
<IconButton
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setAnchorEl(containerRef.current);
handleFilterChange("keywords", query);
handleFilterChange(
"organizers",
selectedContacts.map((a: User) => ({
cn: a.displayName,
cal_address: a.email || "",
partstat: "NEEDS-ACTION",
rsvp: "FALSE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
}))
);
}}
>
<TuneIcon />
</IconButton>
</InputAdornment>
{query && (
<InputAdornment position="end">
<IconButton
onClick={() => {
setQuery("");
setSearch("");
handleFilterChange("keywords", "");
}}
>
<HighlightOffIcon />
</IconButton>
</InputAdornment>
)}
</>
),
}}
/>
)}
/>
)}
</Box>
@@ -232,6 +345,18 @@ export default function SearchBar() {
paper: {
sx: { mt: 1.2, width: extended ? searchWidth : "auto" },
},
transition: {
onExited: () => {
if (
!search.trim() &&
selectedContacts.length === 0 &&
shouldCollapseRef.current
) {
setExtended(false);
}
shouldCollapseRef.current = false;
},
},
}}
>
<Card sx={{ p: 2, pb: 1 }}>
@@ -262,6 +387,7 @@ export default function SearchBar() {
},
},
}}
sx={{ height: "40px" }}
>
<MenuItem value="">
<Typography
@@ -325,6 +451,7 @@ export default function SearchBar() {
onChange={(e) =>
handleFilterChange("keywords", e.target.value)
}
size="small"
/>
</Box>
@@ -369,10 +496,21 @@ export default function SearchBar() {
</CardContent>
<CardActions sx={{ justifyContent: "flex-end", p: 2, gap: 2 }}>
<Button variant="text" onClick={handleClearFilters}>
<Button
variant="text"
onClick={() => {
handleClearFilters();
setSelectedContacts([]);
setSearch("");
shouldCollapseRef.current = true;
}}
>
{t("common.cancel")}
</Button>
<Button variant="contained" onClick={handleSearch}>
<Button
variant="contained"
onClick={() => handleSearch(filters.keywords, filters)}
>
{t("common.search")}
</Button>
</CardActions>
+4 -4
View File
@@ -80,9 +80,9 @@ export default function EventPreviewModal({
const user = useAppSelector((state) => state.user);
const isRecurring = event?.uid?.includes("/");
const isOwn = calendar.ownerEmails?.includes(user.userData.email);
const isOwn = calendar.ownerEmails?.includes(user.userData?.email);
const isOrganizer = event.organizer
? user.userData.email === event.organizer.cal_address
? user.userData?.email === event.organizer.cal_address
: isOwn;
const [showAllAttendees, setShowAllAttendees] = useState(false);
const [openUpdateModal, setOpenUpdateModal] = useState(false);
@@ -274,7 +274,7 @@ export default function EventPreviewModal({
: attendees.slice(0, attendeeDisplayLimit);
const currentUserAttendee = event.attendee?.find(
(person) => person.cal_address === user.userData.email
(person) => person.cal_address === user.userData?.email
);
const organizer = event.attendee?.find(
@@ -365,7 +365,7 @@ export default function EventPreviewModal({
window.open(
`${mailSpaUrl}/mailto/?uri=mailto:${event.attendee
.map((a) => a.cal_address)
.filter((mail) => mail !== user.userData.email)
.filter((mail) => mail !== user.userData?.email)
.join(",")}?subject=${event.title}`
)
}