From 8f2277b27866684b2ec85d6fde79fb55cccfcffd Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Thu, 11 Sep 2025 10:28:54 +0200 Subject: [PATCH] [#72] added duplicate button and fixed tests broken by new interaction between components --- __test__/components/DuplicateEvent.test.tsx | 366 ++++++++++++++++++ .../features/Events/EventDisplay.test.tsx | 16 +- src/components/Event/EventDuplicate.tsx | 52 +++ src/features/Events/EventDisplay.tsx | 2 + src/features/Events/EventModal.tsx | 42 +- 5 files changed, 450 insertions(+), 28 deletions(-) create mode 100644 __test__/components/DuplicateEvent.test.tsx create mode 100644 src/components/Event/EventDuplicate.tsx diff --git a/__test__/components/DuplicateEvent.test.tsx b/__test__/components/DuplicateEvent.test.tsx new file mode 100644 index 0000000..0a89775 --- /dev/null +++ b/__test__/components/DuplicateEvent.test.tsx @@ -0,0 +1,366 @@ +import { screen, fireEvent, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../utils/Renderwithproviders"; +import RepeatEvent from "../../src/components/Event/EventRepeat"; +import EventPopover from "../../src/features/Events/EventModal"; +import { RepetitionObject } from "../../src/features/Events/EventsTypes"; +import { DateSelectArg } from "@fullcalendar/core"; +import { formatDateToYYYYMMDDTHHMMSS } from "../../src/utils/dateUtils"; +import * as eventThunks from "../../src/features/Calendars/CalendarSlice"; +import * as apiUtils from "../../src/utils/apiUtils"; + +const baseRepetition: RepetitionObject = { + freq: "", + interval: 1, + occurrences: 0, + endDate: "", + selectedDays: [], +}; + +const mockOnClose = jest.fn(); +const mockSetSelectedRange = jest.fn(); +const mockCalendarRef = { current: { select: jest.fn() } } as any; + +const preloadedState = { + user: { + userData: { + sub: "test", + email: "test@test.com", + sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", + openpaasId: "667037022b752d0026472254", + }, + organiserData: { + cn: "test", + cal_address: "test@test.com", + }, + }, + calendars: { + list: { + "667037022b752d0026472254/cal1": { + id: "667037022b752d0026472254/cal1", + name: "Calendar 1", + color: "#FF0000", + }, + "667037022b752d0026472254/cal2": { + id: "667037022b752d0026472254/cal2", + name: "Calendar 2", + color: "#00FF00", + }, + }, + pending: false, + }, +}; + +const defaultSelectedRange = { + startStr: "2025-07-18T09:00", + endStr: "2025-07-18T10:00", + start: new Date("2025-07-18T09:00"), + end: new Date("2025-07-18T10:00"), + allDay: false, + resource: undefined, +} as unknown as DateSelectArg; + +function setupRepeatEvent(props?: Partial, state?: any) { + const setRepetition = jest.fn(); + renderWithProviders( + , + state + ); + return { setRepetition }; +} + +async function setupEventPopover( + overrides?: Partial<{ start: string; end: string }> +) { + jest + .spyOn(crypto, "randomUUID") + .mockReturnValue("fixed-uuid-with-correct-format"); + const originalDateResolvedOptions = + new Intl.DateTimeFormat().resolvedOptions(); + jest.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({ + ...originalDateResolvedOptions, + timeZone: "UTC", + }); + + renderWithProviders( + , + preloadedState + ); + act(() => { + fireEvent.change(screen.getByLabelText("Title"), { + target: { value: "Meeting" }, + }); + fireEvent.click(screen.getByLabelText("All day")); + fireEvent.change(screen.getByLabelText("Start"), { + target: { + value: (overrides?.start ?? "2025-07-18T00:00:00.000Z").split("T")[0], + }, + }); + fireEvent.change(screen.getByLabelText("End"), { + target: { + value: (overrides?.end ?? "2025-07-19T00:00:00.000Z").split("T")[0], + }, + }); + fireEvent.click(screen.getByText("Show More")); + }); + const select = screen.getByLabelText(/repetition/i); + userEvent.click(select); + + return jest.spyOn(apiUtils, "api"); +} + +async function expectRRule(expected: any) { + const spyAPi = jest.spyOn(apiUtils, "api"); + + act(() => fireEvent.click(screen.getByText("Save"))); + + await waitFor(() => { + expect(spyAPi).toHaveBeenCalled(); + }); + + const receivedPayload: string = + spyAPi.mock.calls[0][1]?.body?.toString() ?? ""; + const [, , [vevent]] = JSON.parse(receivedPayload); + const rrule = vevent[1].find(([name]: any) => name === "rrule"); + + if (rrule[3].byday) { + expect({ + ...rrule[3], + byday: rrule[3].byday.sort(), + }).toEqual({ + ...expected, + byday: expected.byday.sort(), + }); + } else { + expect(rrule[3]).toEqual(expected); + } +} + +describe("RepeatEvent", () => { + it("renders with no repetition by default", () => { + setupRepeatEvent(); + expect(screen.getByLabelText(/repetition/i)).toBeInTheDocument(); + expect(screen.queryByText(/daily/i)).not.toBeInTheDocument(); + }); + + it("allows selecting repetition frequency", async () => { + const { setRepetition } = setupRepeatEvent(); + const select = screen.getByLabelText(/repetition/i); + act(() => { + userEvent.click(select); + }); + await waitFor(async () => + userEvent.click(await screen.findByText(/repeat weekly/i)) + ); + + expect(setRepetition).toHaveBeenCalledWith( + expect.objectContaining({ freq: "weekly" }) + ); + }); + + it("renders interval input when frequency is selected", () => { + setupRepeatEvent({ freq: "daily", interval: 2 }); + expect(screen.getByText(/interval/i)).toBeInTheDocument(); + expect(screen.getByDisplayValue("2")).toBeInTheDocument(); + }); + + it("updates interval value", () => { + const { setRepetition } = setupRepeatEvent({ freq: "daily", interval: 1 }); + const input = screen.getByDisplayValue("1"); + fireEvent.change(input, { target: { value: "5" } }); + expect(setRepetition).toHaveBeenCalledWith( + expect.objectContaining({ interval: 5 }) + ); + }); + + it("toggles day selection for weekly frequency", () => { + const { setRepetition } = setupRepeatEvent({ + freq: "weekly", + selectedDays: [], + }); + act(() => { + const mondayCheckbox = screen.getByLabelText("MO"); + fireEvent.click(mondayCheckbox); + }); + expect(setRepetition).toHaveBeenCalledWith( + expect.objectContaining({ selectedDays: ["MO"] }) + ); + }); +}); + +describe("Repeat Event API calls", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it("sends correct CalendarEvent payload", async () => { + setupEventPopover(); + + userEvent.click(await screen.findByText(/repeat weekly/i)); + + const spy = jest + .spyOn(eventThunks, "putEventAsync") + .mockImplementation((payload) => () => Promise.resolve(payload) as any); + act(() => fireEvent.click(screen.getByText("Save"))); + await waitFor(() => expect(spy).toHaveBeenCalled()); + + const received = spy.mock.calls[0][0]; + expect(received.cal).toEqual( + preloadedState.calendars.list["667037022b752d0026472254/cal1"] + ); + expect(received.newEvent.title).toBe("Meeting"); + expect( + formatDateToYYYYMMDDTHHMMSS(received.newEvent.start).split("T")[0] + ).toBe("20250718"); + expect( + formatDateToYYYYMMDDTHHMMSS(received.newEvent.end || new Date()).split( + "T" + )[0] + ).toBe("20250719"); + expect(received.newEvent.organizer).toEqual( + preloadedState.user.organiserData + ); + const day = new Date(received.newEvent.start) + .toLocaleString("en-UK", { + weekday: "short", + }) + .slice(0, 2) + .toUpperCase(); + + expect(received.newEvent.repetition).toEqual({ + freq: "weekly", + selectedDays: [day], + }); + expect(received.newEvent.color).toEqual( + preloadedState.calendars.list["667037022b752d0026472254/cal1"].color + ); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + + it("sends correct API payload for repeat daily", async () => { + await setupEventPopover(); + + userEvent.click(await screen.findByText(/repeat daily/i)); + + await expectRRule({ freq: "daily" }); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + + it("sends correct API payload for repeat daily with 2 day interval", async () => { + await setupEventPopover(); + + userEvent.click(await screen.findByText(/repeat daily/i)); + const intervalInput = screen.getByDisplayValue("1"); + fireEvent.change(intervalInput, { target: { value: "2" } }); + + await expectRRule({ freq: "daily", interval: 2 }); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + + it("sends correct API payload for repeat daily for 5 repetitions", async () => { + await setupEventPopover(); + + userEvent.click(await screen.findByText(/repeat daily/i)); + userEvent.click(screen.getByLabelText(/after/i)); + const input = screen.getAllByRole("spinbutton")[1]; + fireEvent.change(input, { target: { value: "5" } }); + + await expectRRule({ freq: "daily", count: 5 }); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + + it("sends correct API payload for repeat daily until now+5days", async () => { + await setupEventPopover(); + + userEvent.click(await screen.findByText(/repeat daily/i)); + userEvent.click(screen.getAllByLabelText(/on/i)[3]); + + const untilInput = screen.getByTestId("end-date"); + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 5); + + fireEvent.change(untilInput, { + target: { value: futureDate.toISOString().split("T")[0] }, + }); + await expectRRule({ + freq: "daily", + until: futureDate.toISOString().split("T")[0], + }); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + + it("sends correct API payload for repeat weekly on Thursday and event day (Friday)", async () => { + await setupEventPopover(); + + userEvent.click(await screen.findByText(/repeat weekly/i)); + userEvent.click(screen.getByLabelText("TH")); + + await expectRRule({ freq: "weekly", byday: ["TH", "FR"] }); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + it("sends correct API payload for repeat weekly on Thursday and event day (Friday) and an interval of 3 weeks", async () => { + await setupEventPopover(); + + userEvent.click(await screen.findByText(/repeat weekly/i)); + + userEvent.click(screen.getByLabelText("TH")); + + const intervalInput = screen.getByDisplayValue("1"); + fireEvent.change(intervalInput, { target: { value: "3" } }); + + await expectRRule({ freq: "weekly", byday: ["TH", "FR"], interval: 3 }); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + it("sends correct API payload for repeat monthly", async () => { + await setupEventPopover(); + + userEvent.click(await screen.findByText(/repeat monthly/i)); + + await expectRRule({ freq: "monthly" }); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + it("sends correct API payload for repeat monthly and end after 5 occurrences", async () => { + await setupEventPopover(); + + userEvent.click(await screen.findByText(/repeat monthly/i)); + userEvent.click(screen.getByLabelText(/after/i)); + const input = screen.getAllByRole("spinbutton")[1]; + fireEvent.change(input, { target: { value: "5" } }); + await expectRRule({ freq: "monthly", count: 5 }); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + it("sends correct API payload for repeat yearly", async () => { + await setupEventPopover(); + + userEvent.click(await screen.findByText(/repeat yearly/i)); + + await expectRRule({ freq: "yearly" }); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + + it("sends correct API payload for repeat yearly, but user first choose to end after 5 occurrences then changed mind and chose to not end", async () => { + await setupEventPopover(); + + userEvent.click(await screen.findByText(/repeat yearly/i)); + userEvent.click(screen.getByLabelText(/after/i)); + const input = screen.getAllByRole("spinbutton")[1]; + fireEvent.change(input, { target: { value: "5" } }); + userEvent.click(screen.getByLabelText(/never/i)); + + await expectRRule({ freq: "yearly" }); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); +}); diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index 28ded30..0fee032 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -1137,13 +1137,7 @@ describe("Event Full Display", () => { const day = new Date(); const preloadedTwoCals = { - user: { - userData: { - sub: "test", - email: "test@test.com", - openpaasId: "667037022b752d0026472254", - }, - }, + ...preloadedState, calendars: { list: { "667037022b752d0026472254/cal1": { @@ -1210,13 +1204,7 @@ describe("Event Full Display", () => { const day = new Date(); const preloadedRecurrence = { - user: { - userData: { - sub: "test", - email: "test@test.com", - openpaasId: "667037022b752d0026472254", - }, - }, + ...preloadedState, calendars: { list: { "667037022b752d0026472254/cal1": { diff --git a/src/components/Event/EventDuplicate.tsx b/src/components/Event/EventDuplicate.tsx new file mode 100644 index 0000000..9e5d453 --- /dev/null +++ b/src/components/Event/EventDuplicate.tsx @@ -0,0 +1,52 @@ +import { IconButton } from "@mui/material"; +import { CalendarEvent } from "../../features/Events/EventsTypes"; +import AddToPhotosIcon from "@mui/icons-material/AddToPhotos"; +import { useRef, useState } from "react"; +import EventPopover from "../../features/Events/EventModal"; +import { CalendarApi, DateSelectArg } from "@fullcalendar/core"; + +export default function EventDuplication({ + onClose, + event, +}: { + onClose: Function; + event: CalendarEvent; +}) { + const [openModal, setOpenModal] = useState(false); + const [selectedRange, setSelectedRange] = useState({ + start: new Date(event.start), + startStr: new Date(event.start).toISOString(), + end: new Date(event.end ?? ""), + endStr: new Date(event.end ?? "").toISOString(), + allDay: event.allday ?? false, + } as DateSelectArg); + const calendarRef = useRef(null); + + const handleClosePopover = () => { + calendarRef.current?.unselect(); + setSelectedRange(null); + setOpenModal(!openModal); + onClose({}, "backdropClick"); + }; + return ( + <> + { + setOpenModal(true); + }} + > + + + + + ); +} diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 5bc56ab..b28dd93 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -47,6 +47,7 @@ import { CalendarEvent, RepetitionObject } from "./EventsTypes"; import { isValidUrl } from "../../utils/apiUtils"; import { formatLocalDateTime } from "./EventModal"; import RepeatEvent from "../../components/Event/EventRepeat"; +import EventDuplication from "../../components/Event/EventDuplicate"; export default function EventDisplayModal({ eventId, @@ -243,6 +244,7 @@ export default function EventDisplayModal({ {/* Close button */} + onClose({}, "backdropClick")} diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index 94aa481..92aa3ae 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -33,6 +33,7 @@ function EventPopover({ selectedRange, setSelectedRange, calendarRef, + event, }: { anchorEl: HTMLElement | null; open: boolean; @@ -40,6 +41,7 @@ function EventPopover({ selectedRange: DateSelectArg | null; setSelectedRange: Function; calendarRef: React.RefObject; + event?: CalendarEvent; }) { const dispatch = useAppDispatch(); @@ -63,20 +65,32 @@ function EventPopover({ const timezones = TIMEZONES.aliases; const [showMore, setShowMore] = useState(false); - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); - const [location, setLocation] = useState(""); - const [start, setStart] = useState(""); - const [end, setEnd] = useState(""); - const [calendarid, setCalendarid] = useState(0); - const [allday, setAllDay] = useState(false); - const [repetition, setRepetition] = useState( - {} as RepetitionObject + const [title, setTitle] = useState(event?.title ?? ""); + const [description, setDescription] = useState(event?.description ?? ""); + const [location, setLocation] = useState(event?.location ?? ""); + const [start, setStart] = useState( + event?.start ? new Date(event.start).toISOString() : "" ); - const [attendees, setAttendees] = useState([]); - const [alarm, setAlarm] = useState(""); - const [eventClass, setEventClass] = useState("PUBLIC"); - const [busy, setBusy] = useState("OPAQUE"); + const [end, setEnd] = useState( + event?.end ? new Date(event.end)?.toISOString() : "" + ); + const [calendarid, setCalendarid] = useState( + event?.calId + ? userPersonnalCalendars.findIndex((e) => e.id === event?.calId) + : 0 + ); + const [allday, setAllDay] = useState(event?.allday ?? false); + const [repetition, setRepetition] = useState( + event?.repetition ?? ({} as RepetitionObject) + ); + const [attendees, setAttendees] = useState( + event?.attendee + ? event.attendee.filter((a) => a.cal_address !== organizer.cal_address) + : [] + ); + const [alarm, setAlarm] = useState(event?.alarm?.trigger ?? ""); + const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC"); + const [busy, setBusy] = useState(event?.transp ?? "OPAQUE"); const [timezone, setTimezone] = useState( Intl.DateTimeFormat().resolvedOptions().timeZone @@ -157,7 +171,7 @@ function EventPopover({ }} > - +