Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
@@ -145,7 +145,7 @@ describe("EventSearchBar", () => {
|
|||||||
fireEvent.click(searchButton);
|
fireEvent.click(searchButton);
|
||||||
|
|
||||||
const searchInput = screen.getByPlaceholderText("common.search");
|
const searchInput = screen.getByPlaceholderText("common.search");
|
||||||
fireEvent.blur(searchInput);
|
fireEvent.mouseDown(document.body);
|
||||||
expect(searchInput).not.toBeInTheDocument();
|
expect(searchInput).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@ describe("EventSearchBar", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(searchSpy).toHaveBeenCalledWith({
|
expect(searchSpy).toHaveBeenCalledWith({
|
||||||
filters: {
|
filters: {
|
||||||
keywords: "test",
|
keywords: "",
|
||||||
organizers: [],
|
organizers: [],
|
||||||
attendees: [],
|
attendees: [],
|
||||||
searchIn: ["user1/cal1"],
|
searchIn: ["user1/cal1"],
|
||||||
@@ -263,4 +263,82 @@ describe("EventSearchBar", () => {
|
|||||||
expect(searchSpy).not.toHaveBeenCalled();
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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 { useEffect, useState } from "react";
|
import { type ReactNode, useCallback, 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";
|
||||||
@@ -19,8 +19,8 @@ import { SnackbarAlert } from "../Loading/SnackBarAlert";
|
|||||||
export interface User {
|
export interface User {
|
||||||
email: string;
|
email: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl: string;
|
avatarUrl?: string;
|
||||||
openpaasId: string;
|
openpaasId?: string;
|
||||||
color?: Record<string, string>;
|
color?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,9 +41,10 @@ export function PeopleSearch({
|
|||||||
onToggleEventPreview,
|
onToggleEventPreview,
|
||||||
placeholder,
|
placeholder,
|
||||||
inputSlot,
|
inputSlot,
|
||||||
|
customRenderInput,
|
||||||
}: {
|
}: {
|
||||||
selectedUsers: User[];
|
selectedUsers: User[];
|
||||||
onChange: Function;
|
onChange: (event: any, users: User[]) => void;
|
||||||
objectTypes: string[];
|
objectTypes: string[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
freeSolo?: boolean;
|
freeSolo?: boolean;
|
||||||
@@ -52,6 +53,11 @@ export function PeopleSearch({
|
|||||||
inputSlot?: (
|
inputSlot?: (
|
||||||
params: ExtendedAutocompleteRenderInputParams
|
params: ExtendedAutocompleteRenderInputParams
|
||||||
) => React.ReactNode;
|
) => React.ReactNode;
|
||||||
|
customRenderInput?: (
|
||||||
|
params: AutocompleteRenderInputParams,
|
||||||
|
query: string,
|
||||||
|
setQuery: (value: string) => void
|
||||||
|
) => ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -93,7 +99,7 @@ export function PeopleSearch({
|
|||||||
setOptions(res);
|
setOptions(res);
|
||||||
setHasSearched(true);
|
setHasSearched(true);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setHasSearched(false);
|
setHasSearched(false);
|
||||||
setSnackbarMessage(t("peopleSearch.searchError"));
|
setSnackbarMessage(t("peopleSearch.searchError"));
|
||||||
@@ -112,6 +118,56 @@ export function PeopleSearch({
|
|||||||
};
|
};
|
||||||
}, [objectTypes, query, t]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
@@ -122,7 +178,11 @@ export function PeopleSearch({
|
|||||||
autoComplete={false}
|
autoComplete={false}
|
||||||
clearOnBlur={false}
|
clearOnBlur={false}
|
||||||
blurOnSelect={true}
|
blurOnSelect={true}
|
||||||
open={isOpen && !!query && (loading || hasSearched)}
|
open={
|
||||||
|
customRenderInput
|
||||||
|
? isOpen && !!query && (loading || options.length > 0)
|
||||||
|
: isOpen && !!query && (loading || hasSearched)
|
||||||
|
}
|
||||||
onOpen={() => setIsOpen(true)}
|
onOpen={() => setIsOpen(true)}
|
||||||
onClose={() => setIsOpen(false)}
|
onClose={() => setIsOpen(false)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -138,13 +198,18 @@ export function PeopleSearch({
|
|||||||
return option;
|
return option;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
sx={{
|
||||||
|
"& .MuiAutocomplete-inputRoot": {
|
||||||
|
py: 0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
filterSelectedOptions
|
filterSelectedOptions
|
||||||
value={selectedUsers}
|
value={selectedUsers}
|
||||||
inputValue={query}
|
inputValue={query}
|
||||||
onInputChange={(event, value) => setQuery(value)}
|
onInputChange={(_event, value) => setQuery(value)}
|
||||||
onChange={(event, value) => {
|
onChange={(event, value) => {
|
||||||
const last = value[value.length - 1];
|
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(
|
const invalidEmailMessage = t("peopleSearch.invalidEmail").replace(
|
||||||
"%{email}",
|
"%{email}",
|
||||||
last
|
last
|
||||||
@@ -153,96 +218,18 @@ export function PeopleSearch({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setInputError(null);
|
setInputError(null);
|
||||||
const mapped = value.map((v: any) =>
|
const mapped = value.map((v: string | User) =>
|
||||||
typeof v === "string" ? { email: v } : v
|
typeof v === "string"
|
||||||
|
? { email: v.trim(), displayName: v.trim() }
|
||||||
|
: v
|
||||||
);
|
);
|
||||||
onChange(event, mapped);
|
onChange(event, mapped);
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => {
|
renderInput={(params) =>
|
||||||
const inputProps = {
|
customRenderInput
|
||||||
...params.InputProps,
|
? customRenderInput(params, query, setQuery)
|
||||||
startAdornment: (
|
: defaultRenderInput(params)
|
||||||
<>
|
}
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
renderOption={(props, option) => {
|
renderOption={(props, option) => {
|
||||||
if (selectedUsers.find((u) => u.email === option.email)) return null;
|
if (selectedUsers.find((u) => u.email === option.email)) return null;
|
||||||
const { key, ...otherProps } = props as any;
|
const { key, ...otherProps } = props as any;
|
||||||
|
|||||||
@@ -282,19 +282,21 @@ export default function CalendarSearch({
|
|||||||
|
|
||||||
const cals = await Promise.all(
|
const cals = await Promise.all(
|
||||||
value.map(async (user: User) => {
|
value.map(async (user: User) => {
|
||||||
const cals = (await getCalendars(
|
if (user?.openpaasId) {
|
||||||
user.openpaasId,
|
const cals = (await getCalendars(
|
||||||
"sharedPublic=true&"
|
user.openpaasId,
|
||||||
)) as Record<string, any>;
|
"sharedPublic=true&"
|
||||||
return cals._embedded?.["dav:calendar"]
|
)) as Record<string, any>;
|
||||||
? cals._embedded["dav:calendar"].map(
|
return cals._embedded?.["dav:calendar"]
|
||||||
(cal: Record<string, any>) => ({ cal, owner: user })
|
? cals._embedded["dav:calendar"].map(
|
||||||
)
|
(cal: Record<string, any>) => ({ cal, owner: user })
|
||||||
: { cal: undefined, owner: user };
|
)
|
||||||
|
: { cal: undefined, owner: user };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
setSelectedCalendars(cals.flat().filter(Boolean));
|
||||||
setSelectedCalendars(cals.flat());
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -302,16 +304,17 @@ export default function CalendarSearch({
|
|||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
selectedCal={selectedCal}
|
selectedCal={selectedCal}
|
||||||
onRemove={(cal) => {
|
onRemove={(cal) => {
|
||||||
|
if (!cal.cal?._links?.self?.href) return;
|
||||||
setSelectedCalendars((prev) =>
|
setSelectedCalendars((prev) =>
|
||||||
prev.filter(
|
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 (
|
if (
|
||||||
!selectedCal.find(
|
!selectedCal.find(
|
||||||
(c) =>
|
(c) =>
|
||||||
cal.owner.email === c.owner.email &&
|
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) =>
|
setSelectedUsers((prev) =>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export async function handleRSVP(
|
|||||||
const newEvent = {
|
const newEvent = {
|
||||||
...event,
|
...event,
|
||||||
attendee: event.attendee?.map((a) =>
|
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") {
|
if (typeOfAction === "solo") {
|
||||||
@@ -39,7 +39,7 @@ export async function handleRSVP(
|
|||||||
const calendarRange = getCalendarRange(new Date(event.start));
|
const calendarRange = getCalendarRange(new Date(event.start));
|
||||||
|
|
||||||
// Update PARTSTAT on ALL VEVENTs (master + exceptions)
|
// Update PARTSTAT on ALL VEVENTs (master + exceptions)
|
||||||
await updateSeriesPartstat(event, user.userData.email, rsvp);
|
await updateSeriesPartstat(event, user.userData?.email, rsvp);
|
||||||
|
|
||||||
if (calendars) {
|
if (calendars) {
|
||||||
await refreshCalendars(dispatch, calendars, calendarRange);
|
await refreshCalendars(dispatch, calendars, calendarRange);
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} 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 HighlightOffIcon from "@mui/icons-material/HighlightOff";
|
||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import TuneIcon from "@mui/icons-material/Tune";
|
import TuneIcon from "@mui/icons-material/Tune";
|
||||||
@@ -26,6 +27,7 @@ import { setView } from "../../features/Settings/SettingsSlice";
|
|||||||
import { userAttendee } from "../../features/User/userDataTypes";
|
import { userAttendee } from "../../features/User/userDataTypes";
|
||||||
import UserSearch from "../Attendees/AttendeeSearch";
|
import UserSearch from "../Attendees/AttendeeSearch";
|
||||||
import { CalendarItemList } from "../Calendar/CalendarItemList";
|
import { CalendarItemList } from "../Calendar/CalendarItemList";
|
||||||
|
import { PeopleSearch, User } from "../Attendees/PeopleSearch";
|
||||||
|
|
||||||
export default function SearchBar() {
|
export default function SearchBar() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -33,15 +35,16 @@ export default function SearchBar() {
|
|||||||
const calendars = Object.values(
|
const calendars = Object.values(
|
||||||
useAppSelector((state) => state.calendars.list)
|
useAppSelector((state) => state.calendars.list)
|
||||||
);
|
);
|
||||||
const userId = useAppSelector((state) => state.user.userData.openpaasId);
|
const userId = useAppSelector((state) => state.user.userData?.openpaasId);
|
||||||
const personnalCalendars = calendars.filter(
|
const personnalCalendars = userId
|
||||||
(c) => c.id.split("/")[0] === userId
|
? calendars.filter((c) => c.id.split("/")[0] === userId)
|
||||||
);
|
: [];
|
||||||
const sharedCalendars = calendars.filter(
|
const sharedCalendars = userId
|
||||||
(c) => c.id.split("/")[0] !== userId
|
? calendars.filter((c) => c.id.split("/")[0] !== userId)
|
||||||
);
|
: calendars;
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [selectedContacts, setSelectedContacts] = useState<User[]>([]);
|
||||||
const [extended, setExtended] = useState(false);
|
const [extended, setExtended] = useState(false);
|
||||||
|
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
@@ -63,28 +66,37 @@ export default function SearchBar() {
|
|||||||
width: "55vw",
|
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 = (
|
const handleFilterChange = (
|
||||||
field: string,
|
field: FilterField,
|
||||||
value: string | userAttendee[]
|
value: string | userAttendee[]
|
||||||
) => {
|
) => {
|
||||||
setFilters((prev) => ({ ...prev, [field]: value }));
|
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 = () => {
|
function buildQuery(
|
||||||
setFilters({
|
searchQuery: string,
|
||||||
searchIn: "my-calendars",
|
filters: {
|
||||||
keywords: "",
|
searchIn: string;
|
||||||
organizers: [] as userAttendee[],
|
keywords: string;
|
||||||
attendees: [] as userAttendee[],
|
organizers: userAttendee[];
|
||||||
});
|
attendees: userAttendee[];
|
||||||
setAnchorEl(null);
|
}
|
||||||
setExtended(false);
|
) {
|
||||||
};
|
const trimmedSearch = searchQuery.trim();
|
||||||
|
|
||||||
const handleSearch = async () => {
|
|
||||||
const trimmedSearch = search.trim();
|
|
||||||
const trimmedKeywords = filters.keywords.trim();
|
const trimmedKeywords = filters.keywords.trim();
|
||||||
|
|
||||||
// Block search if all search criteria are empty
|
// Block search if all search criteria are empty
|
||||||
@@ -116,28 +128,91 @@ export default function SearchBar() {
|
|||||||
attendees: filters.attendees.map((u) => u.cal_address),
|
attendees: filters.attendees.map((u) => u.cal_address),
|
||||||
searchIn: searchInCalendars,
|
searchIn: searchInCalendars,
|
||||||
};
|
};
|
||||||
|
return {
|
||||||
|
search: trimmedSearch,
|
||||||
|
filters: cleanedFilters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(
|
const handleClearFilters = () => {
|
||||||
searchEventsAsync({
|
setFilters({
|
||||||
search: trimmedSearch,
|
searchIn: "my-calendars",
|
||||||
filters: cleanedFilters,
|
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"));
|
dispatch(setView("search"));
|
||||||
setAnchorEl(null);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box
|
<Box
|
||||||
ref={searchBoxRef}
|
ref={containerRef}
|
||||||
sx={{
|
sx={{
|
||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
height: "44px",
|
height: "44px",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: extended ? searchWidth : "auto",
|
width: extended ? searchWidth : "auto",
|
||||||
|
|
||||||
transition: "width 0.25s ease-out",
|
transition: "width 0.25s ease-out",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -148,76 +223,114 @@ export default function SearchBar() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{extended && (
|
{extended && (
|
||||||
<TextField
|
<PeopleSearch
|
||||||
fullWidth
|
selectedUsers={selectedContacts}
|
||||||
autoFocus
|
onChange={(event, users) => {
|
||||||
placeholder={t("common.search")}
|
handleContactSelect(event, users);
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
|
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>
|
</Box>
|
||||||
@@ -232,6 +345,18 @@ export default function SearchBar() {
|
|||||||
paper: {
|
paper: {
|
||||||
sx: { mt: 1.2, width: extended ? searchWidth : "auto" },
|
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 }}>
|
<Card sx={{ p: 2, pb: 1 }}>
|
||||||
@@ -262,6 +387,7 @@ export default function SearchBar() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
sx={{ height: "40px" }}
|
||||||
>
|
>
|
||||||
<MenuItem value="">
|
<MenuItem value="">
|
||||||
<Typography
|
<Typography
|
||||||
@@ -325,6 +451,7 @@ export default function SearchBar() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleFilterChange("keywords", e.target.value)
|
handleFilterChange("keywords", e.target.value)
|
||||||
}
|
}
|
||||||
|
size="small"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -369,10 +496,21 @@ export default function SearchBar() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<CardActions sx={{ justifyContent: "flex-end", p: 2, gap: 2 }}>
|
<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")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" onClick={handleSearch}>
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => handleSearch(filters.keywords, filters)}
|
||||||
|
>
|
||||||
{t("common.search")}
|
{t("common.search")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardActions>
|
</CardActions>
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ export default function EventPreviewModal({
|
|||||||
const user = useAppSelector((state) => state.user);
|
const user = useAppSelector((state) => state.user);
|
||||||
|
|
||||||
const isRecurring = event?.uid?.includes("/");
|
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
|
const isOrganizer = event.organizer
|
||||||
? user.userData.email === event.organizer.cal_address
|
? user.userData?.email === event.organizer.cal_address
|
||||||
: isOwn;
|
: isOwn;
|
||||||
const [showAllAttendees, setShowAllAttendees] = useState(false);
|
const [showAllAttendees, setShowAllAttendees] = useState(false);
|
||||||
const [openUpdateModal, setOpenUpdateModal] = useState(false);
|
const [openUpdateModal, setOpenUpdateModal] = useState(false);
|
||||||
@@ -274,7 +274,7 @@ export default function EventPreviewModal({
|
|||||||
: attendees.slice(0, attendeeDisplayLimit);
|
: attendees.slice(0, attendeeDisplayLimit);
|
||||||
|
|
||||||
const currentUserAttendee = event.attendee?.find(
|
const currentUserAttendee = event.attendee?.find(
|
||||||
(person) => person.cal_address === user.userData.email
|
(person) => person.cal_address === user.userData?.email
|
||||||
);
|
);
|
||||||
|
|
||||||
const organizer = event.attendee?.find(
|
const organizer = event.attendee?.find(
|
||||||
@@ -365,7 +365,7 @@ export default function EventPreviewModal({
|
|||||||
window.open(
|
window.open(
|
||||||
`${mailSpaUrl}/mailto/?uri=mailto:${event.attendee
|
`${mailSpaUrl}/mailto/?uri=mailto:${event.attendee
|
||||||
.map((a) => a.cal_address)
|
.map((a) => a.cal_address)
|
||||||
.filter((mail) => mail !== user.userData.email)
|
.filter((mail) => mail !== user.userData?.email)
|
||||||
.join(",")}?subject=${event.title}`
|
.join(",")}?subject=${event.title}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user