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" }}
>