[#50] added attendee search and tests

This commit is contained in:
Camille Moussu
2025-07-30 15:22:28 +02:00
committed by Benoit TELLIER
parent 9a58c485ac
commit d7b3d4e077
6 changed files with 260 additions and 36 deletions
+111 -10
View File
@@ -1,20 +1,20 @@
import { screen, fireEvent, waitFor } from "@testing-library/react";
import * as eventThunks from "../../../src/features/Calendars/CalendarSlice";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import EventPopover from "../../../src/features/Events/EventModal";
import { DateSelectArg } from "@fullcalendar/core"; import { DateSelectArg } from "@fullcalendar/core";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import preview from "jest-preview"; import preview from "jest-preview";
import * as eventThunks from "../../../src/features/Calendars/CalendarSlice";
import EventPopover from "../../../src/features/Events/EventModal";
import { api } from "../../../src/utils/apiUtils";
import { formatDateToYYYYMMDDTHHMMSS } from "../../../src/utils/dateUtils"; import { formatDateToYYYYMMDDTHHMMSS } from "../../../src/utils/dateUtils";
import { renderWithProviders } from "../../utils/Renderwithproviders";
jest.mock("../../../src/utils/apiUtils");
describe("EventPopover", () => { describe("EventPopover", () => {
const mockOnClose = jest.fn(); const mockOnClose = jest.fn();
const mockSetSelectedRange = jest.fn(); const mockSetSelectedRange = jest.fn();
const mockCalendarRef = { current: { select: jest.fn() } } as any; const mockCalendarRef = { current: { select: jest.fn() } } as any;
beforeEach(() => {
jest.clearAllMocks();
});
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
@@ -25,7 +25,7 @@ describe("EventPopover", () => {
}, },
organiserData: { organiserData: {
cn: "test", cn: "test",
cal_address: "mailto:test@test.com", cal_address: "test@test.com",
}, },
}, },
calendars: { calendars: {
@@ -45,6 +45,44 @@ describe("EventPopover", () => {
}, },
}; };
const mockUsers = [
{
id: "john@example.com",
objectType: "user",
emailAddresses: [
{
value: "john@example.com",
type: "default",
},
],
names: [
{
displayName: "John Doe",
type: "default",
},
],
},
{
id: "jane@example.com",
objectType: "user",
emailAddresses: [
{
value: "jane@example.com",
type: "default",
},
],
names: [
{
displayName: "Jane Smith",
type: "default",
},
],
},
];
(api.post as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockUsers),
});
const defaultSelectedRange = { const defaultSelectedRange = {
startStr: "2025-07-18T09:00", startStr: "2025-07-18T09:00",
endStr: "2025-07-18T10:00", endStr: "2025-07-18T10:00",
@@ -130,6 +168,69 @@ describe("EventPopover", () => {
expect(screen.getAllByRole("combobox")[0]).toHaveTextContent("Calendar 2"); expect(screen.getAllByRole("combobox")[0]).toHaveTextContent("Calendar 2");
}); });
it("adds a attendee", async () => {
jest.useFakeTimers();
renderPopover();
fireEvent.change(screen.getByLabelText("Title"), {
target: { value: "newEvent" },
});
const select = screen.getByLabelText("Search user");
act(() => {
select.focus();
fireEvent.mouseDown(select);
userEvent.type(select, "john");
});
await act(async () => {
jest.advanceTimersByTime(400);
});
await waitFor(() => expect(api.post).toHaveBeenCalledTimes(1));
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
await act(async () => {
userEvent.click(screen.getByText("John Doe"));
});
const spy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
const receivedPayload = spy.mock.calls[0][0];
expect(receivedPayload.cal).toEqual(
preloadedState.calendars.list["667037022b752d0026472254/cal1"]
);
expect(receivedPayload.newEvent.attendee).toHaveLength(2);
expect(receivedPayload.newEvent.attendee).toStrictEqual([
{
cn: "test",
cal_address: "test@test.com",
partstat: "ACCEPTED",
rsvp: "FALSE",
role: "CHAIR",
cutype: "INDIVIDUAL",
},
{
cn: "John Doe",
cal_address: "john@example.com",
partstat: "NEED_ACTION",
rsvp: "FALSE",
role: "CHAIR",
cutype: "INDIVIDUAL",
},
]);
});
it("dispatches putEventAsync and calls onClose when Save is clicked", async () => { it("dispatches putEventAsync and calls onClose when Save is clicked", async () => {
renderPopover(); renderPopover();
@@ -142,7 +243,7 @@ describe("EventPopover", () => {
description: "Discuss project", description: "Discuss project",
location: "Zoom", location: "Zoom",
repetition: "", repetition: "",
organizer: { cn: "test", cal_address: "mailto:test@test.com" }, organizer: { cn: "test", cal_address: "test@test.com" },
timezone: "Europe/Paris", timezone: "Europe/Paris",
transp: "OPAQUE", transp: "OPAQUE",
}; };
+2 -2
View File
@@ -1,5 +1,5 @@
import { clientConfig } from "../../../src/features/User/oidcAuth"; import { clientConfig } from "../../../src/features/User/oidcAuth";
import getOpenPaasUserId from "../../../src/features/User/userAPI"; import getOpenPaasUser from "../../../src/features/User/userAPI";
import { api } from "../../../src/utils/apiUtils"; import { api } from "../../../src/utils/apiUtils";
jest.mock("../../../src/utils/apiUtils"); jest.mock("../../../src/utils/apiUtils");
@@ -14,7 +14,7 @@ describe("getOpenPaasUserId", () => {
json: jest.fn().mockResolvedValue(mockUser), json: jest.fn().mockResolvedValue(mockUser),
}); });
const result = await getOpenPaasUserId(); const result = await getOpenPaasUser();
expect(api.get).toHaveBeenCalledWith("api/user"); expect(api.get).toHaveBeenCalledWith("api/user");
expect(result).toEqual(mockUser); expect(result).toEqual(mockUser);
@@ -0,0 +1,88 @@
import {
Autocomplete,
Avatar,
CircularProgress,
ListItem,
ListItemAvatar,
ListItemText,
TextField,
} from "@mui/material";
import { useEffect, useState } from "react";
import { searchUsers } from "../../features/User/userAPI";
interface User {
email: string;
displayName: string;
avatarUrl: string;
}
export default function UserSearch({
setAttendees,
}: {
setAttendees: Function;
}) {
const [query, setQuery] = useState("");
const [options, setOptions] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const delayDebounceFn = setTimeout(async () => {
setLoading(true);
const res = await searchUsers(query);
setOptions(res);
setLoading(false);
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [query]);
return (
<Autocomplete
multiple
options={options}
loading={loading}
filterOptions={(x) => x}
fullWidth
getOptionLabel={(option) => option.displayName || option.email}
filterSelectedOptions
onInputChange={(event, value) => setQuery(value)}
onChange={(event, value) =>
setAttendees(
value.map((a) => ({
cn: a.displayName,
cal_address: a.email,
partstat: "NEED_ACTION",
rsvp: "FALSE",
role: "CHAIR",
cutype: "INDIVIDUAL",
}))
)
}
renderInput={(params) => (
<TextField
{...params}
label="Search user"
InputProps={{
...params.InputProps,
endAdornment: (
<>
{loading ? (
<CircularProgress color="inherit" size={20} />
) : null}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
renderOption={(props, option) => (
<ListItem {...props} key={option.email} disableGutters>
<ListItemAvatar>
<Avatar src={option.avatarUrl} alt={option.displayName} />
</ListItemAvatar>
<ListItemText primary={option.displayName} secondary={option.email} />
</ListItem>
)}
/>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Calendars } from "./CalendarTypes"; import { Calendars } from "./CalendarTypes";
import { CalendarEvent } from "../Events/EventsTypes"; import { CalendarEvent } from "../Events/EventsTypes";
import { getCalendar, getCalendars } from "./CalendarApi"; import { getCalendar, getCalendars } from "./CalendarApi";
import getOpenPaasUserId from "../User/userAPI"; import { getOpenPaasUserId } from "../User/userAPI";
import { parseCalendarEvent } from "../Events/eventUtils"; import { parseCalendarEvent } from "../Events/eventUtils";
import { deleteEvent, putEvent } from "../Events/EventApi"; import { deleteEvent, putEvent } from "../Events/EventApi";
import { formatDateToYYYYMMDDTHHMMSS } from "../../utils/dateUtils"; import { formatDateToYYYYMMDDTHHMMSS } from "../../utils/dateUtils";
+37 -23
View File
@@ -1,24 +1,25 @@
import React, { useEffect, useState } from "react";
import { addEvent, putEventAsync } from "../Calendars/CalendarSlice";
import { CalendarEvent } from "./EventsTypes";
import { CalendarApi, DateSelectArg } from "@fullcalendar/core"; import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { import {
Popover,
TextField,
Button,
Box, Box,
Typography, Button,
Select,
MenuItem,
SelectChangeEvent,
FormControl, FormControl,
InputLabel, InputLabel,
MenuItem,
Popover,
Select,
SelectChangeEvent,
TextField,
Typography,
} from "@mui/material"; } from "@mui/material";
import { Calendars } from "../Calendars/CalendarTypes"; import React, { useEffect, useState } from "react";
import { putEvent } from "./EventApi"; import { useAppDispatch, useAppSelector } from "../../app/hooks";
import AttendeeSelector from "../../components/Attendees/AttendeeSearch";
import { TIMEZONES } from "../../utils/timezone-data"; import { TIMEZONES } from "../../utils/timezone-data";
import { CheckBox, Repeat } from "@mui/icons-material"; import { putEventAsync } from "../Calendars/CalendarSlice";
import { Calendars } from "../Calendars/CalendarTypes";
import { userAttendee } from "../User/userDataTypes";
import { CalendarEvent } from "./EventsTypes";
import { createSelector } from "@reduxjs/toolkit";
function EventPopover({ function EventPopover({
anchorEl, anchorEl,
@@ -39,14 +40,21 @@ function EventPopover({
const organizer = useAppSelector((state) => state.user.organiserData); const organizer = useAppSelector((state) => state.user.organiserData);
const userId = useAppSelector((state) => state.user.userData.openpaasId); const userId = useAppSelector((state) => state.user.userData.openpaasId);
const userPersonnalCalendars: Calendars[] = useAppSelector((state) => const selectPersonnalCalendars = createSelector(
Object.keys(state.calendars.list).map((id) => { (state) => state.calendars,
if (id.split("/")[0] === userId) { (calendars) =>
return state.calendars.list[id]; Object.keys(calendars.list)
} .map((id) => {
return {} as Calendars; if (id.split("/")[0] === userId) {
}) return calendars.list[id];
).filter((calendar) => calendar.id); }
return {} as Calendars;
})
.filter((calendar) => calendar.id)
);
const userPersonnalCalendars: Calendars[] = useAppSelector(
selectPersonnalCalendars
);
const timezones = TIMEZONES.aliases; const timezones = TIMEZONES.aliases;
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
@@ -57,6 +65,7 @@ function EventPopover({
const [calendarid, setCalendarid] = useState(0); const [calendarid, setCalendarid] = useState(0);
const [allday, setAllDay] = useState(false); const [allday, setAllDay] = useState(false);
const [repetition, setRepetition] = useState(""); const [repetition, setRepetition] = useState("");
const [attendees, setAttendees] = useState<userAttendee[]>([]);
const [timezone, setTimezone] = useState( const [timezone, setTimezone] = useState(
Intl.DateTimeFormat().resolvedOptions().timeZone Intl.DateTimeFormat().resolvedOptions().timeZone
); );
@@ -96,6 +105,11 @@ function EventPopover({
if (end) { if (end) {
newEvent.end = new Date(end); newEvent.end = new Date(end);
} }
if (attendees.length > 0) {
newEvent.attendee = newEvent.attendee.concat(attendees);
}
dispatch( dispatch(
putEventAsync({ putEventAsync({
cal: userPersonnalCalendars[calendarid], cal: userPersonnalCalendars[calendarid],
@@ -246,7 +260,7 @@ function EventPopover({
<MenuItem value={"yearly"}>Repeat yearly</MenuItem> <MenuItem value={"yearly"}>Repeat yearly</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
<AttendeeSelector setAttendees={setAttendees} />
<FormControl fullWidth margin="dense" size="small"> <FormControl fullWidth margin="dense" size="small">
<InputLabel id="timezone-select-label">Time Zone</InputLabel> <InputLabel id="timezone-select-label">Time Zone</InputLabel>
<Select <Select
+21
View File
@@ -4,3 +4,24 @@ export default async function getOpenPaasUser() {
const user = await api.get(`api/user`).json(); const user = await api.get(`api/user`).json();
return user; return user;
} }
export async function searchUsers(query: string) {
const response: any[] = await api
.post(`api/people/search/`, {
body: JSON.stringify({
limit: 10,
objectTypes: ["user", "group", "contact", "ldap"],
q: query,
}),
})
.json();
return response
.filter((user) => user.objectType === "user")
.map((user) => ({
email: user.emailAddresses?.[0]?.value || "",
displayName:
user.names?.[0]?.displayName || user.emailAddresses?.[0]?.value,
avatarUrl: user.photos?.[0]?.url || "",
}));
}