From c81153a1192334f55f1e9a264efe000844e0bfd7 Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Mon, 15 Dec 2025 22:10:17 +0100 Subject: [PATCH] [#523] changed search bar to work like an autocomplete (#406) Co-authored-by: Camille Moussu --- .../features/Search/EventSearchBar.test.tsx | 82 ++++- src/components/Attendees/PeopleSearch.tsx | 177 +++++---- src/components/Calendar/CalendarSearch.tsx | 29 +- .../Event/eventHandlers/eventHandlers.ts | 4 +- src/components/Menubar/EventSearchBar.tsx | 342 ++++++++++++------ src/features/Events/EventDisplayPreview.tsx | 8 +- 6 files changed, 424 insertions(+), 218 deletions(-) diff --git a/__test__/features/Search/EventSearchBar.test.tsx b/__test__/features/Search/EventSearchBar.test.tsx index 042cfc4..e6d9c06 100644 --- a/__test__/features/Search/EventSearchBar.test.tsx +++ b/__test__/features/Search/EventSearchBar.test.tsx @@ -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(, 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(, 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(, 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(, 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(, 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"); + }); }); diff --git a/src/components/Attendees/PeopleSearch.tsx b/src/components/Attendees/PeopleSearch.tsx index a8a2c93..87f1f4a 100644 --- a/src/components/Attendees/PeopleSearch.tsx +++ b/src/components/Attendees/PeopleSearch.tsx @@ -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; } @@ -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) => ( + <> + + { + if (e.key === "Enter" && onToggleEventPreview) { + e.preventDefault(); + onToggleEventPreview(); + } + }} + slotProps={{ + input: { + ...params.InputProps, + autoComplete: "off", + startAdornment: ( + <> + + {params.InputProps.startAdornment} + + ), + endAdornment: ( + <> + {loading ? ( + + ) : null} + {params.InputProps.endAdornment} + + ), + }, + }} + /> + + ), + [inputError, t, onToggleEventPreview, loading] + ); + return ( <> 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: ( - <> - - {params.InputProps.startAdornment} - - ), - endAdornment: ( - <> - {loading ? ( - - ) : null} - {params.InputProps.endAdornment} - - ), - }; - - const enhancedParamsWithInputProps = { - ...params, - InputProps: inputProps, - inputProps: { - ...params.inputProps, - autoComplete: "off", - }, - }; - - const { InputProps, ...enhancedParams } = - enhancedParamsWithInputProps; - - const handleEnterKey = (e: React.KeyboardEvent) => { - 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 ( - <> - - {inputSlot({ - ...enhancedParamsWithInputProps, - error: !!inputError, - helperText: inputError, - placeholder: searchPlaceholder, - label: "", - onKeyDown: handleEnterKey, - })} - - ); - } - - return ( - <> - - - - ); - }} + 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; diff --git a/src/components/Calendar/CalendarSearch.tsx b/src/components/Calendar/CalendarSearch.tsx index 2c76217..9cdc1f2 100644 --- a/src/components/Calendar/CalendarSearch.tsx +++ b/src/components/Calendar/CalendarSearch.tsx @@ -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; - return cals._embedded?.["dav:calendar"] - ? cals._embedded["dav:calendar"].map( - (cal: Record) => ({ cal, owner: user }) - ) - : { cal: undefined, owner: user }; + if (user?.openpaasId) { + const cals = (await getCalendars( + user.openpaasId, + "sharedPublic=true&" + )) as Record; + return cals._embedded?.["dav:calendar"] + ? cals._embedded["dav:calendar"].map( + (cal: Record) => ({ 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) => diff --git a/src/components/Event/eventHandlers/eventHandlers.ts b/src/components/Event/eventHandlers/eventHandlers.ts index 8951aec..64a4c72 100644 --- a/src/components/Event/eventHandlers/eventHandlers.ts +++ b/src/components/Event/eventHandlers/eventHandlers.ts @@ -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); diff --git a/src/components/Menubar/EventSearchBar.tsx b/src/components/Menubar/EventSearchBar.tsx index 5c26232..3d3c734 100644 --- a/src/components/Menubar/EventSearchBar.tsx +++ b/src/components/Menubar/EventSearchBar.tsx @@ -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([]); const [extended, setExtended] = useState(false); const [filters, setFilters] = useState({ @@ -63,28 +66,37 @@ export default function SearchBar() { width: "55vw", }, }; - const searchBoxRef = useRef(null); + const inputRef = useRef(null); + const containerRef = useRef(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 ( <> @@ -148,76 +223,114 @@ export default function SearchBar() { )} {extended && ( - { - 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: ( - - - - ), - endAdornment: ( - <> - - setAnchorEl(searchBoxRef.current)} - > - - - - {search && ( - - { - setSearch(""); - handleFilterChange("keywords", ""); - }} - > - - - - )} - - ), + { + handleContactSelect(event, users); }} + objectTypes={["user", "contact"]} + onToggleEventPreview={() => {}} + customRenderInput={( + params: AutocompleteRenderInputParams, + query: string, + setQuery: (value: string) => void + ) => ( + { + inputRef.current = el; + const ref = params.InputProps.ref; + if (typeof ref === "function") { + ref(el); + } else if (ref && "current" in ref) { + ( + ref as React.MutableRefObject + ).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: ( + <> + + + + {params.InputProps.startAdornment} + + ), + endAdornment: ( + <> + {params.InputProps.endAdornment} + + 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", + })) + ); + }} + > + + + + {query && ( + + { + setQuery(""); + setSearch(""); + handleFilterChange("keywords", ""); + }} + > + + + + )} + + ), + }} + /> + )} /> )} @@ -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; + }, + }, }} > @@ -262,6 +387,7 @@ export default function SearchBar() { }, }, }} + sx={{ height: "40px" }} > handleFilterChange("keywords", e.target.value) } + size="small" /> @@ -369,10 +496,21 @@ export default function SearchBar() { - - diff --git a/src/features/Events/EventDisplayPreview.tsx b/src/features/Events/EventDisplayPreview.tsx index 085551e..c1651a7 100644 --- a/src/features/Events/EventDisplayPreview.tsx +++ b/src/features/Events/EventDisplayPreview.tsx @@ -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}` ) }