From 80028b3f26ad3ffd7ab9b777430c5f9974061b70 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Thu, 4 Sep 2025 15:02:55 +0200 Subject: [PATCH 1/9] [#77] added repetition proper display and editing --- src/components/Event/EventRepeat.tsx | 104 +++-- src/features/Events/EventDisplay.tsx | 617 ++++++++++++++------------- src/features/Events/eventUtils.ts | 7 +- 3 files changed, 400 insertions(+), 328 deletions(-) diff --git a/src/components/Event/EventRepeat.tsx b/src/components/Event/EventRepeat.tsx index 433fc2f..37a6e67 100644 --- a/src/components/Event/EventRepeat.tsx +++ b/src/components/Event/EventRepeat.tsx @@ -6,18 +6,15 @@ import { MenuItem, Box, Stack, - Paper, Typography, TextField, Checkbox, - List, - ListItem, FormControlLabel, FormGroup, Radio, RadioGroup, } from "@mui/material"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { RepetitionObject } from "../../features/Events/EventsTypes"; export default function RepeatEvent({ @@ -29,23 +26,31 @@ export default function RepeatEvent({ setRepetition: Function; isOwn?: boolean; }) { - console.log(JSON.stringify(repetition)); - const repetitionValues = ["day", "week", "month", "year"]; - const [interval, setInterval] = useState(repetition.interval ?? 0); - const [selectedDays, setSelectedDays] = useState( - repetition.selectedDays ?? [] - ); - const [endOption, setEndOption] = useState(""); - const [occurrences, setOccurrences] = useState(repetition.occurrences) ?? 0; - const [endDate, setEndDate] = useState(repetition.endDate ?? ""); const days = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]; - const handleDayChange = (day: string) => { - setSelectedDays((prev: string[]) => - prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day] - ); + // derive endOption based on repetition + const getEndOption = () => { + if (repetition.occurrences && repetition.occurrences > 0) return "after"; + if (repetition.endDate) return "on"; + return "never"; }; + + const [endOption, setEndOption] = useState(getEndOption()); + + // keep endOption in sync if repetition changes from parent + useEffect(() => { + setEndOption(getEndOption()); + }, [repetition.occurrences, repetition.endDate]); + + const handleDayChange = (day: string) => { + const updatedDays = repetition.selectedDays?.includes(day) + ? repetition.selectedDays.filter((d) => d !== day) + : [...(repetition.selectedDays ?? []), day]; + + setRepetition({ ...repetition, selectedDays: updatedDays }); + }; + return ( Repetition @@ -64,18 +69,24 @@ export default function RepeatEvent({ Repeat monthly Repeat yearly + {repetition.freq && ( + {/* Interval */} Interval: setInterval(Number(e.target.value))} + value={repetition.interval ?? 0} + onChange={(e) => + setRepetition({ + ...repetition, + interval: Number(e.target.value), + }) + } size="small" sx={{ width: 80 }} /> - { repetitionValues[ @@ -84,6 +95,8 @@ export default function RepeatEvent({ } + + {/* Weekly selection */} {repetition.freq === "weekly" && ( @@ -95,7 +108,9 @@ export default function RepeatEvent({ key={day} control={ handleDayChange(day)} /> } @@ -105,13 +120,38 @@ export default function RepeatEvent({ )} + + {/* End options */} End: setEndOption(e.target.value)} + onChange={(e) => { + const value = e.target.value; + setEndOption(value); + + if (value === "never") { + setRepetition({ ...repetition, occurrences: 0, endDate: "" }); + } + if (value === "after") { + setRepetition({ + freq: repetition.freq, + interval: repetition.interval, + occurence: Number(e.target.value), + }); + } + if (value === "on") { + setRepetition({ + freq: repetition.freq, + interval: repetition.interval, + endDate: new Date(e.target.value) + .toISOString() + .slice(0, 16), + }); + } + }} > setOccurrences(Number(e.target.value))} + value={repetition.occurrences ?? 0} + onChange={(e) => + setRepetition({ + freq: repetition.freq, + interval: repetition.interval, + occurence: Number(e.target.value), + }) + } sx={{ width: 100 }} inputProps={{ min: 1 }} disabled={endOption !== "after"} @@ -148,8 +194,14 @@ export default function RepeatEvent({ setEndDate(e.target.value)} + value={repetition.endDate ?? ""} + onChange={(e) => + setRepetition({ + freq: repetition.freq, + interval: repetition.interval, + endDate: e.target.value, + }) + } disabled={endOption !== "on"} /> diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index d716b34..40343f3 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -124,7 +124,8 @@ export default function EventDisplayModal({ if (!event || !calendar) { onClose({}, "backdropClick"); } - }, [open, eventId, dispatch, onClose]); + setRepetition(event.repetition ?? ({} as RepetitionObject)); + }, [open, eventId, dispatch, onClose, event]); if (!event || !calendar) return null; @@ -224,315 +225,331 @@ export default function EventDisplayModal({ return ( - - {/* Close button */} - - onClose({}, "backdropClick")}> - - - - - - - - {/* Title */} - setTitle(e.target.value)} - size="small" - margin="dense" - /> - - {/* RSVP */} - {currentUserAttendee && isOwnCal && ( - - - - - - - - - )} - - {/* Calendar selector */} - - Calendar - - + + + - {/* Dates */} - - setStart(formatLocalDateTime(new Date(e.target.value))) - } - size="small" - margin="dense" - InputLabelProps={{ shrink: true }} - /> + - - setEnd(formatLocalDateTime(new Date(e.target.value))) - } - size="small" - margin="dense" - InputLabelProps={{ shrink: true }} - /> + + {/* Title */} + setTitle(e.target.value)} + size="small" + margin="dense" + /> - + + + + + + + + )} + + {/* Calendar selector */} + + Calendar + setAlarm(e.target.value)} - > - No Alarm - 1 minute - 2 minutes - 10 minutes - 15 minutes - 30 minutes - 1 hours - 2 hours - 5 hours - 12 hours - 1 day - 2 days - 1 week - - - - - Visibility - - - {/* Error */} - {event.error && ( - - } - text={event.error} - error - /> - )} - - )} - - - - - {isOwn && ( - { - onClose({}, "backdropClick"); - dispatch( - deleteEventAsync({ calId, eventId, eventURL: event.URL }) - ); + labelId="calendar-select-label" + value={calendarid.toString()} + label="Calendar" + onChange={(e: SelectChangeEvent) => { + const newId = Number(e.target.value); + setCalendarid(newId); + setNewCalId(userPersonnalCalendars[newId].id); }} > - - - )} - + {calList} + + + + {/* Dates */} + + setStart(formatLocalDateTime(new Date(e.target.value))) + } + size="small" + margin="dense" + InputLabelProps={{ shrink: true }} + /> + + + setEnd(formatLocalDateTime(new Date(e.target.value))) + } + size="small" + margin="dense" + InputLabelProps={{ shrink: true }} + /> + + { + const endDate = new Date(end); + const startDate = new Date(start); + setAllDay(!allday); + if (endDate.getDate() === startDate.getDate()) { + endDate.setDate(startDate.getDate() + 1); + setEnd(formatLocalDateTime(endDate)); + } + }} + /> + } + label="All day" + /> + + {/* Description & Location */} + setDescription(e.target.value)} + size="small" + margin="dense" + multiline + rows={2} + /> {isOwn && ( - + { + const newAttendeeList = attendees.concat(value); + setAttendees(newAttendeeList); + }} + /> )} - - - + + setLocation(e.target.value)} + size="small" + margin="dense" + /> + + {/* Video */} + {event.x_openpass_videoconference && ( + } + text="Video conference available" + data={event.x_openpass_videoconference} + /> + )} + + {/* Attendees */} + {event.attendee?.length > 0 && ( + + Attendees: + {organizer.cal_address && + renderAttendeeBadge(organizer, "org", true)} + {(showAllAttendees + ? attendees + : attendees.slice(0, attendeeDisplayLimit) + ).map((a, idx) => ( + + {renderAttendeeBadge(a, idx.toString())} + {isOwn && ( + { + const newAttendeesList = [...attendees]; + newAttendeesList.splice(idx, 1); + setAttendees(newAttendeesList); + }} + > + + + )} + + ))} + {attendees.length > attendeeDisplayLimit && ( + setShowAllAttendees(!showAllAttendees)} + > + {showAllAttendees + ? "Show less" + : `Show more (${ + attendees.length - attendeeDisplayLimit + } more)`} + + )} + + )} + + + + {/* Extended options */} + {showMore && ( + <> + + + + Alarm + + + + + Visibility + + + {/* Error */} + {event.error && ( + + } + text={event.error} + error + /> + )} + + )} + + + + + {isOwn && ( + { + onClose({}, "backdropClick"); + dispatch( + deleteEventAsync({ calId, eventId, eventURL: event.URL }) + ); + }} + > + + + )} + + + {isOwn && ( + + )} + + + + ); } diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index 088eb0f..f76fd6b 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -84,18 +84,21 @@ export function parseCalendarEvent( break; case "rrule": event.repetition = { freq: value.freq.toLowerCase() }; + console.log(value); if (value.byday) { + console.log(value.byday); event.repetition.selectedDays = value.byday; } if (value.until) { - event.repetition.selectedDays = value.endDate; + event.repetition.endDate = value.until; } if (value.count) { - event.repetition.selectedDays = value.occurrences; + event.repetition.occurrences = value.count; } if (value.interval) { event.repetition.interval = value.interval; } + console.log(event.repetition); break; } } From 88b36efbf3d8da9b6bf49b09e2c378294fef0554 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Thu, 4 Sep 2025 15:56:52 +0200 Subject: [PATCH 2/9] [#77] fixed occurences typo --- src/components/Event/EventRepeat.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/Event/EventRepeat.tsx b/src/components/Event/EventRepeat.tsx index 37a6e67..4576e48 100644 --- a/src/components/Event/EventRepeat.tsx +++ b/src/components/Event/EventRepeat.tsx @@ -139,16 +139,14 @@ export default function RepeatEvent({ setRepetition({ freq: repetition.freq, interval: repetition.interval, - occurence: Number(e.target.value), + occurences: 0, }); } if (value === "on") { setRepetition({ freq: repetition.freq, interval: repetition.interval, - endDate: new Date(e.target.value) - .toISOString() - .slice(0, 16), + endDate: new Date().toISOString().slice(0, 16), }); } }} @@ -173,7 +171,7 @@ export default function RepeatEvent({ setRepetition({ freq: repetition.freq, interval: repetition.interval, - occurence: Number(e.target.value), + occurences: Number(e.target.value), }) } sx={{ width: 100 }} From 6de32808515e8e9f9eeb060dd59f087c3a9ae394 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Thu, 4 Sep 2025 18:06:32 +0200 Subject: [PATCH 3/9] [#77] fixed occurrences other typo, augmented the match window to update the calendar --- src/components/Event/EventRepeat.tsx | 20 +++++++++++--------- src/features/Calendars/CalendarSlice.ts | 15 +++++++++++---- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/components/Event/EventRepeat.tsx b/src/components/Event/EventRepeat.tsx index 4576e48..3155a77 100644 --- a/src/components/Event/EventRepeat.tsx +++ b/src/components/Event/EventRepeat.tsx @@ -31,7 +31,7 @@ export default function RepeatEvent({ // derive endOption based on repetition const getEndOption = () => { - if (repetition.occurrences && repetition.occurrences > 0) return "after"; + if (repetition.occurrences && repetition.occurrences >= 0) return "after"; if (repetition.endDate) return "on"; return "never"; }; @@ -40,7 +40,9 @@ export default function RepeatEvent({ // keep endOption in sync if repetition changes from parent useEffect(() => { - setEndOption(getEndOption()); + if (!endOption) { + setEndOption(getEndOption()); + } }, [repetition.occurrences, repetition.endDate]); const handleDayChange = (day: string) => { @@ -77,7 +79,7 @@ export default function RepeatEvent({ Interval: setRepetition({ ...repetition, @@ -137,15 +139,15 @@ export default function RepeatEvent({ } if (value === "after") { setRepetition({ - freq: repetition.freq, - interval: repetition.interval, - occurences: 0, + ...repetition, + occurrences: 0, + endDate: "", }); } if (value === "on") { setRepetition({ - freq: repetition.freq, - interval: repetition.interval, + ...repetition, + occurrences: 0, endDate: new Date().toISOString().slice(0, 16), }); } @@ -171,7 +173,7 @@ export default function RepeatEvent({ setRepetition({ freq: repetition.freq, interval: repetition.interval, - occurences: Number(e.target.value), + occurrences: Number(e.target.value), }) } sx={{ width: 100 }} diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index dca589e..f36b427 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -71,11 +71,18 @@ export const putEventAsync = createAsyncThunk< { cal: Calendars; newEvent: CalendarEvent } // Arg type >("calendars/putEvent", async ({ cal, newEvent }) => { const response = await putEvent(newEvent); + const eventDate = new Date(newEvent.start); + + const weekStart = new Date(eventDate); + weekStart.setHours(0, 0, 0, 0); + weekStart.setDate(eventDate.getDate() - eventDate.getDay()); + + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekStart.getDate() + 7); + const calEvents = (await getCalendar(cal.id, { - start: formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.start)), - end: formatDateToYYYYMMDDTHHMMSS( - new Date(new Date(newEvent.start).getTime() + 86400000) - ), + start: formatDateToYYYYMMDDTHHMMSS(weekStart), + end: formatDateToYYYYMMDDTHHMMSS(weekEnd), })) as Record; const events: CalendarEvent[] = calEvents._embedded["dav:item"].flatMap( (eventdata: any) => { From 8efeea8102b9ec6228c9c1270abe67b0c36c1dd2 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Fri, 5 Sep 2025 12:36:58 +0200 Subject: [PATCH 4/9] [#77] added tests and fixed bug --- __test__/components/RepeatEvent.test.tsx | 346 ++++++++++++++++++++ __test__/features/Events/eventUtils.test.ts | 4 +- src/components/Attendees/AttendeeSearch.tsx | 10 +- src/components/Event/EventRepeat.tsx | 8 +- src/features/Events/eventUtils.ts | 3 - 5 files changed, 358 insertions(+), 13 deletions(-) create mode 100644 __test__/components/RepeatEvent.test.tsx diff --git a/__test__/components/RepeatEvent.test.tsx b/__test__/components/RepeatEvent.test.tsx new file mode 100644 index 0000000..4c1eafb --- /dev/null +++ b/__test__/components/RepeatEvent.test.tsx @@ -0,0 +1,346 @@ +import { screen, fireEvent, waitFor } from "@testing-library/react"; +import RepeatEvent from "../../src/components/Event/EventRepeat"; +import { RepetitionObject } from "../../src/features/Events/EventsTypes"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../utils/Renderwithproviders"; +import EventPopover from "../../src/features/Events/EventModal"; +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"; + +describe("RepeatEvent", () => { + const baseRepetition: RepetitionObject = { + freq: "", + interval: 1, + occurrences: 0, + endDate: "", + selectedDays: [], + }; + + const setup = (props?: Partial, preloadedState?: any) => { + const setRepetition = jest.fn(); + renderWithProviders( + , + preloadedState + ); + return { setRepetition }; + }; + + it("renders with no repetition by default", () => { + setup(); + expect(screen.getByLabelText(/repetition/i)).toBeInTheDocument(); + expect(screen.queryByText(/daily/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/weekly/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/monthly/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/yearly/i)).not.toBeInTheDocument(); + }); + + it("allows selecting repetition frequency", async () => { + const { setRepetition } = setup(); + const select = screen.getByLabelText(/repetition/i); + userEvent.click(select); + userEvent.click(await screen.findByText(/repeat weekly/i)); + expect(setRepetition).toHaveBeenCalledWith( + expect.objectContaining({ freq: "weekly" }) + ); + }); + + it("renders interval input when frequency is selected", () => { + setup({ freq: "daily", interval: 2 }); + expect(screen.getByText(/interval/i)).toBeInTheDocument(); + expect(screen.getByDisplayValue("2")).toBeInTheDocument(); + }); + + it("updates interval value", () => { + const { setRepetition } = setup({ 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 } = setup({ freq: "weekly", selectedDays: [] }); + const mondayCheckbox = screen.getByLabelText("MO"); + userEvent.click(mondayCheckbox); + expect(setRepetition).toHaveBeenCalledWith( + expect.objectContaining({ selectedDays: ["MO"] }) + ); + }); +}); + +describe("Repeat Event api calls", () => { + 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; + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + it("Given XYZ input in the component, data in CalendarEvent is correct", async () => { + renderWithProviders( + , + preloadedState + ); + const newEvent = { + title: "Meeting", + start: "2025-07-18T00:00:00.000Z", + end: "2025-07-19T00:00:00.000Z", + allday: false, + uid: "6045c603-11ab-43c5-bc30-0641420bb3a8", + organizer: { cn: "test", cal_address: "test@test.com" }, + repetition: { freq: "weekly" }, + }; + + // Fill inputs + fireEvent.change(screen.getByLabelText("Title"), { + target: { value: newEvent.title }, + }); + fireEvent.click(screen.getByLabelText("All day")); + fireEvent.change(screen.getByLabelText("Start"), { + target: { value: newEvent.start.split("T")[0] }, + }); + fireEvent.change(screen.getByLabelText("End"), { + target: { value: newEvent.end.split("T")[0] }, + }); + + fireEvent.click(screen.getByText("Show More")); + + const select = screen.getByLabelText(/repetition/i); + userEvent.click(select); + userEvent.click(await screen.findByText(/repeat weekly/i)); + + 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.title).toBe(newEvent.title); + expect( + formatDateToYYYYMMDDTHHMMSS(receivedPayload.newEvent.start).split("T")[0] + ).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.start)).split("T")[0]); + expect( + formatDateToYYYYMMDDTHHMMSS( + receivedPayload.newEvent.end || new Date() + ).split("T")[0] + ).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.end)).split("T")[0]); + expect(receivedPayload.newEvent.organizer).toEqual(newEvent.organizer); + expect(receivedPayload.newEvent.repetition).toEqual(newEvent.repetition); + expect(receivedPayload.newEvent.color).toEqual( + preloadedState.calendars.list["667037022b752d0026472254/cal1"].color + ); + + // onClose should be called + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + + it("Given XYZ input in the component, data in api call is correct", async () => { + jest + .spyOn(crypto, "randomUUID") + .mockReturnValue("bfe2c579-acce-456b-8b91-c557bb3b4f89"); + renderWithProviders( + , + preloadedState + ); + const newEvent = { + title: "Meeting", + start: "2025-07-18T00:00:00.000Z", + end: "2025-07-19T00:00:00.000Z", + allday: false, + uid: "6045c603-11ab-43c5-bc30-0641420bb3a8", + organizer: { cn: "test", cal_address: "test@test.com" }, + repetition: { freq: "weekly" }, + }; + + // Fill inputs + fireEvent.change(screen.getByLabelText("Title"), { + target: { value: newEvent.title }, + }); + fireEvent.click(screen.getByLabelText("All day")); + fireEvent.change(screen.getByLabelText("Start"), { + target: { value: newEvent.start.split("T")[0] }, + }); + fireEvent.change(screen.getByLabelText("End"), { + target: { value: newEvent.end.split("T")[0] }, + }); + + fireEvent.click(screen.getByText("Show More")); + + const select = screen.getByLabelText(/repetition/i); + userEvent.click(select); + userEvent.click(await screen.findByText(/repeat weekly/i)); + const mondayCheckbox = screen.getByLabelText("MO"); + userEvent.click(mondayCheckbox); + + const radio = screen.getByLabelText(/after/i); + userEvent.click(radio); + const input = screen.getAllByRole("spinbutton")[1]; + fireEvent.change(input, { target: { value: "3" } }); + const spyAPi = jest.spyOn(apiUtils, "api"); + + fireEvent.click(screen.getByText("Save")); + + await waitFor(() => { + expect(spyAPi).toHaveBeenCalled(); + }); + + const receivedPayload = spyAPi.mock.calls[0][1]?.body; + expect(receivedPayload).toEqual( + JSON.stringify([ + "vcalendar", + [], + [ + [ + "vevent", + [ + ["uid", {}, "text", "bfe2c579-acce-456b-8b91-c557bb3b4f89"], + ["transp", {}, "text", "OPAQUE"], + ["dtstart", { tzid: "Europe/Paris" }, "date", "2025-07-18"], + ["class", {}, "text", "PUBLIC"], + ["x-openpaas-videoconference", {}, "unknown", null], + ["summary", {}, "text", "Meeting"], + ["dtend", { tzid: "Europe/Paris" }, "date", "2025-07-19"], + [ + "organizer", + { cn: "test" }, + "cal-address", + "mailto:test@test.com", + ], + [ + "rrule", + {}, + "recur", + { freq: "weekly", count: 3, byday: ["MO"] }, + ], + [ + "attendee", + { + partstat: "ACCEPTED", + rsvp: "FALSE", + role: "CHAIR", + cutype: "INDIVIDUAL", + cn: "test", + }, + "cal-address", + "mailto:test@test.com", + ], + ], + [], + ], + [ + "vtimezone", + [["tzid", {}, "text", "Europe/Paris"]], + [ + [ + "daylight", + [ + ["tzoffsetfrom", {}, "utc-offset", "+01:00"], + ["tzoffsetto", {}, "utc-offset", "+02:00"], + ["tzname", {}, "text", "CEST"], + ["dtstart", {}, "date-time", "1970-03-29T02:00:00"], + [ + "rrule", + {}, + "recur", + { freq: "YEARLY", bymonth: 3, byday: "-1SU" }, + ], + ], + [], + ], + [ + "standard", + [ + ["tzoffsetfrom", {}, "utc-offset", "+02:00"], + ["tzoffsetto", {}, "utc-offset", "+01:00"], + ["tzname", {}, "text", "CET"], + ["dtstart", {}, "date-time", "1970-10-25T03:00:00"], + [ + "rrule", + {}, + "recur", + { freq: "YEARLY", bymonth: 10, byday: "-1SU" }, + ], + ], + [], + ], + ], + ], + ], + ]) + ); + + // onClose should be called + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); +}); diff --git a/__test__/features/Events/eventUtils.test.ts b/__test__/features/Events/eventUtils.test.ts index 145af5e..2807740 100644 --- a/__test__/features/Events/eventUtils.test.ts +++ b/__test__/features/Events/eventUtils.test.ts @@ -280,7 +280,7 @@ describe("calendarEventToJCal", () => { allday: true, location: "Room 101", description: "Discuss project roadmap.", - repetition: { freq: "WEEKLY" }, + repetition: { freq: "WEEKLY", interval: 2 }, organizer: { cn: "Alice", cal_address: "alice@example.com", @@ -315,7 +315,7 @@ describe("calendarEventToJCal", () => { ["location", {}, "text", "Room 101"], ["description", {}, "text", "Discuss project roadmap."], ["x-openpaas-videoconference", {}, "unknown", null], - ["rrule", {}, "recur", { freq: "WEEKLY" }], + ["rrule", {}, "recur", { freq: "WEEKLY", interval: 2 }], [ "organizer", { cn: "Alice" }, diff --git a/src/components/Attendees/AttendeeSearch.tsx b/src/components/Attendees/AttendeeSearch.tsx index 44636b5..641579f 100644 --- a/src/components/Attendees/AttendeeSearch.tsx +++ b/src/components/Attendees/AttendeeSearch.tsx @@ -32,10 +32,12 @@ export default function UserSearch({ useEffect(() => { const delayDebounceFn = setTimeout(async () => { - setLoading(true); - const res = await searchUsers(query); - setOptions(res); - setLoading(false); + if (query) { + setLoading(true); + const res = await searchUsers(query); + setOptions(res); + setLoading(false); + } }, 300); return () => clearTimeout(delayDebounceFn); diff --git a/src/components/Event/EventRepeat.tsx b/src/components/Event/EventRepeat.tsx index 3155a77..7e5843e 100644 --- a/src/components/Event/EventRepeat.tsx +++ b/src/components/Event/EventRepeat.tsx @@ -171,8 +171,8 @@ export default function RepeatEvent({ value={repetition.occurrences ?? 0} onChange={(e) => setRepetition({ - freq: repetition.freq, - interval: repetition.interval, + ...repetition, + endDate: "", occurrences: Number(e.target.value), }) } @@ -197,8 +197,8 @@ export default function RepeatEvent({ value={repetition.endDate ?? ""} onChange={(e) => setRepetition({ - freq: repetition.freq, - interval: repetition.interval, + ...repetition, + occurrences: 0, endDate: e.target.value, }) } diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index f76fd6b..0e19bab 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -84,9 +84,7 @@ export function parseCalendarEvent( break; case "rrule": event.repetition = { freq: value.freq.toLowerCase() }; - console.log(value); if (value.byday) { - console.log(value.byday); event.repetition.selectedDays = value.byday; } if (value.until) { @@ -98,7 +96,6 @@ export function parseCalendarEvent( if (value.interval) { event.repetition.interval = value.interval; } - console.log(event.repetition); break; } } From 0549a77d9229c955b32f1f8519a1ee8b5d5091ce Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Fri, 5 Sep 2025 13:07:51 +0200 Subject: [PATCH 5/9] [#77] fixed when getEventAsync is dispatched to prevent missing data --- .../features/Events/EventDisplay.test.tsx | 47 ++++++++++++------- src/features/Events/EventDisplay.tsx | 6 --- src/features/Events/EventDisplayPreview.tsx | 1 + 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index d69b891..d878e95 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -474,6 +474,37 @@ describe("Event Preview Display", () => { const updatedEvent = spy.mock.calls[0][0].newEvent; expect(updatedEvent.attendee[0].partstat).toBe("DECLINED"); }); + it("handles Edit click", async () => { + const spy = jest + .spyOn(eventThunks, "getEventAsync") + .mockImplementation((payload) => { + return () => + Promise.resolve({ + calId: payload.calId, + event: + preloadedState.calendars.list["667037022b752d0026472254/cal1"] + .events["event1"], + }) as any; + }); + + renderWithProviders( + , + preloadedState + ); + + fireEvent.click(screen.getByTestId("EditIcon")); + + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + }); + expect(screen.getByText("Edit Event")).toBeInTheDocument(); + }); }); describe("Event Full Display", () => { @@ -917,18 +948,6 @@ describe("Event Full Display", () => { expect(updatedEvent.attendee[0].partstat).toBe("DECLINED"); }); it("toggle Show More reveals extra fields", async () => { - const spy = jest - .spyOn(eventThunks, "getEventAsync") - .mockImplementation((payload) => { - return () => - Promise.resolve({ - calId: payload.calId, - event: - preloadedState.calendars.list["667037022b752d0026472254/cal1"] - .events["event1"], - }) as any; - }); - renderWithProviders( { fireEvent.click(screen.getByText("Show More")); }); - await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); - console.log(spy); await waitFor(() => { expect(screen.getByLabelText(/Alarm/i)).toBeInTheDocument(); expect(screen.getByLabelText(/Repetition/i)).toBeInTheDocument(); diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 40343f3..b310542 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -182,13 +182,7 @@ export default function EventDisplayModal({ onClose({}, "backdropClick"); }; - const [detailsLoaded, setDetailsLoaded] = useState(false); - const handleToggleShowMore = async () => { - if (!detailsLoaded) { - await dispatch(getEventAsync(event)); - setDetailsLoaded(true); - } setShowMore(!showMore); }; diff --git a/src/features/Events/EventDisplayPreview.tsx b/src/features/Events/EventDisplayPreview.tsx index 3f9a220..a70fb77 100644 --- a/src/features/Events/EventDisplayPreview.tsx +++ b/src/features/Events/EventDisplayPreview.tsx @@ -136,6 +136,7 @@ export default function EventPreviewModal({ size="small" onClick={async () => { setOpenFullDisplay(!openFullDisplay); + await dispatch(getEventAsync(event)); }} > From 2f071303de3c5477a6e0dd26e8ee3a2df40fca05 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Fri, 5 Sep 2025 16:41:05 +0200 Subject: [PATCH 6/9] [#77] fixed delete refresh issue and added more tests --- __test__/components/RepeatEvent.test.tsx | 544 +++++++++---------- __test__/features/Events/EventModal.test.tsx | 1 - src/components/Event/EventRepeat.tsx | 1 + src/features/Calendars/CalendarSlice.ts | 15 +- 4 files changed, 287 insertions(+), 274 deletions(-) diff --git a/__test__/components/RepeatEvent.test.tsx b/__test__/components/RepeatEvent.test.tsx index 4c1eafb..065d331 100644 --- a/__test__/components/RepeatEvent.test.tsx +++ b/__test__/components/RepeatEvent.test.tsx @@ -1,63 +1,171 @@ -import { screen, fireEvent, waitFor } from "@testing-library/react"; -import RepeatEvent from "../../src/components/Event/EventRepeat"; -import { RepetitionObject } from "../../src/features/Events/EventsTypes"; +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"); + expect(rrule[3]).toEqual(expected); +} + describe("RepeatEvent", () => { - const baseRepetition: RepetitionObject = { - freq: "", - interval: 1, - occurrences: 0, - endDate: "", - selectedDays: [], - }; - - const setup = (props?: Partial, preloadedState?: any) => { - const setRepetition = jest.fn(); - renderWithProviders( - , - preloadedState - ); - return { setRepetition }; - }; - it("renders with no repetition by default", () => { - setup(); + setupRepeatEvent(); expect(screen.getByLabelText(/repetition/i)).toBeInTheDocument(); expect(screen.queryByText(/daily/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/weekly/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/monthly/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/yearly/i)).not.toBeInTheDocument(); }); it("allows selecting repetition frequency", async () => { - const { setRepetition } = setup(); + const { setRepetition } = setupRepeatEvent(); const select = screen.getByLabelText(/repetition/i); - userEvent.click(select); - userEvent.click(await screen.findByText(/repeat weekly/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", () => { - setup({ freq: "daily", interval: 2 }); + setupRepeatEvent({ freq: "daily", interval: 2 }); expect(screen.getByText(/interval/i)).toBeInTheDocument(); expect(screen.getByDisplayValue("2")).toBeInTheDocument(); }); it("updates interval value", () => { - const { setRepetition } = setup({ freq: "daily", interval: 1 }); + const { setRepetition } = setupRepeatEvent({ freq: "daily", interval: 1 }); const input = screen.getByDisplayValue("1"); fireEvent.change(input, { target: { value: "5" } }); expect(setRepetition).toHaveBeenCalledWith( @@ -66,281 +174,173 @@ describe("RepeatEvent", () => { }); it("toggles day selection for weekly frequency", () => { - const { setRepetition } = setup({ freq: "weekly", selectedDays: [] }); - const mondayCheckbox = screen.getByLabelText("MO"); - userEvent.click(mondayCheckbox); + 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", () => { - 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; +describe("Repeat Event API calls", () => { beforeEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); }); - it("Given XYZ input in the component, data in CalendarEvent is correct", async () => { - renderWithProviders( - , - preloadedState - ); - const newEvent = { - title: "Meeting", - start: "2025-07-18T00:00:00.000Z", - end: "2025-07-19T00:00:00.000Z", - allday: false, - uid: "6045c603-11ab-43c5-bc30-0641420bb3a8", - organizer: { cn: "test", cal_address: "test@test.com" }, - repetition: { freq: "weekly" }, - }; - // Fill inputs - fireEvent.change(screen.getByLabelText("Title"), { - target: { value: newEvent.title }, - }); - fireEvent.click(screen.getByLabelText("All day")); - fireEvent.change(screen.getByLabelText("Start"), { - target: { value: newEvent.start.split("T")[0] }, - }); - fireEvent.change(screen.getByLabelText("End"), { - target: { value: newEvent.end.split("T")[0] }, - }); + it("sends correct CalendarEvent payload", async () => { + setupEventPopover(); - fireEvent.click(screen.getByText("Show More")); - - const select = screen.getByLabelText(/repetition/i); - userEvent.click(select); userEvent.click(await screen.findByText(/repeat weekly/i)); const spy = jest .spyOn(eventThunks, "putEventAsync") - .mockImplementation((payload) => { - return () => Promise.resolve(payload) as any; - }); + .mockImplementation((payload) => () => Promise.resolve(payload) as any); + act(() => fireEvent.click(screen.getByText("Save"))); + await waitFor(() => expect(spy).toHaveBeenCalled()); - fireEvent.click(screen.getByText("Save")); - - await waitFor(() => { - expect(spy).toHaveBeenCalled(); - }); - - const receivedPayload = spy.mock.calls[0][0]; - expect(receivedPayload.cal).toEqual( + const received = spy.mock.calls[0][0]; + expect(received.cal).toEqual( preloadedState.calendars.list["667037022b752d0026472254/cal1"] ); - - expect(receivedPayload.newEvent.title).toBe(newEvent.title); + expect(received.newEvent.title).toBe("Meeting"); expect( - formatDateToYYYYMMDDTHHMMSS(receivedPayload.newEvent.start).split("T")[0] - ).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.start)).split("T")[0]); + formatDateToYYYYMMDDTHHMMSS(received.newEvent.start).split("T")[0] + ).toBe("20250718"); expect( - formatDateToYYYYMMDDTHHMMSS( - receivedPayload.newEvent.end || new Date() - ).split("T")[0] - ).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.end)).split("T")[0]); - expect(receivedPayload.newEvent.organizer).toEqual(newEvent.organizer); - expect(receivedPayload.newEvent.repetition).toEqual(newEvent.repetition); - expect(receivedPayload.newEvent.color).toEqual( + formatDateToYYYYMMDDTHHMMSS(received.newEvent.end || new Date()).split( + "T" + )[0] + ).toBe("20250719"); + expect(received.newEvent.organizer).toEqual( + preloadedState.user.organiserData + ); + expect(received.newEvent.repetition).toEqual({ freq: "weekly" }); + expect(received.newEvent.color).toEqual( preloadedState.calendars.list["667037022b752d0026472254/cal1"].color ); - - // onClose should be called expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); }); - it("Given XYZ input in the component, data in api call is correct", async () => { - jest - .spyOn(crypto, "randomUUID") - .mockReturnValue("bfe2c579-acce-456b-8b91-c557bb3b4f89"); - renderWithProviders( - , - preloadedState - ); - const newEvent = { - title: "Meeting", - start: "2025-07-18T00:00:00.000Z", - end: "2025-07-19T00:00:00.000Z", - allday: false, - uid: "6045c603-11ab-43c5-bc30-0641420bb3a8", - organizer: { cn: "test", cal_address: "test@test.com" }, - repetition: { freq: "weekly" }, - }; + it("sends correct API payload for repeat daily", async () => { + await setupEventPopover(); - // Fill inputs - fireEvent.change(screen.getByLabelText("Title"), { - target: { value: newEvent.title }, - }); - fireEvent.click(screen.getByLabelText("All day")); - fireEvent.change(screen.getByLabelText("Start"), { - target: { value: newEvent.start.split("T")[0] }, - }); - fireEvent.change(screen.getByLabelText("End"), { - target: { value: newEvent.end.split("T")[0] }, - }); + userEvent.click(await screen.findByText(/repeat daily/i)); - fireEvent.click(screen.getByText("Show More")); + await expectRRule({ freq: "daily" }); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); - const select = screen.getByLabelText(/repetition/i); - userEvent.click(select); - userEvent.click(await screen.findByText(/repeat weekly/i)); - const mondayCheckbox = screen.getByLabelText("MO"); - userEvent.click(mondayCheckbox); + it("sends correct API payload for repeat daily with 2 day interval", async () => { + await setupEventPopover(); - const radio = screen.getByLabelText(/after/i); - userEvent.click(radio); + 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: "3" } }); - const spyAPi = jest.spyOn(apiUtils, "api"); + fireEvent.change(input, { target: { value: "5" } }); - fireEvent.click(screen.getByText("Save")); + await expectRRule({ freq: "daily", count: 5 }); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); - await waitFor(() => { - expect(spyAPi).toHaveBeenCalled(); + 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"); + }); - const receivedPayload = spyAPi.mock.calls[0][1]?.body; - expect(receivedPayload).toEqual( - JSON.stringify([ - "vcalendar", - [], - [ - [ - "vevent", - [ - ["uid", {}, "text", "bfe2c579-acce-456b-8b91-c557bb3b4f89"], - ["transp", {}, "text", "OPAQUE"], - ["dtstart", { tzid: "Europe/Paris" }, "date", "2025-07-18"], - ["class", {}, "text", "PUBLIC"], - ["x-openpaas-videoconference", {}, "unknown", null], - ["summary", {}, "text", "Meeting"], - ["dtend", { tzid: "Europe/Paris" }, "date", "2025-07-19"], - [ - "organizer", - { cn: "test" }, - "cal-address", - "mailto:test@test.com", - ], - [ - "rrule", - {}, - "recur", - { freq: "weekly", count: 3, byday: ["MO"] }, - ], - [ - "attendee", - { - partstat: "ACCEPTED", - rsvp: "FALSE", - role: "CHAIR", - cutype: "INDIVIDUAL", - cn: "test", - }, - "cal-address", - "mailto:test@test.com", - ], - ], - [], - ], - [ - "vtimezone", - [["tzid", {}, "text", "Europe/Paris"]], - [ - [ - "daylight", - [ - ["tzoffsetfrom", {}, "utc-offset", "+01:00"], - ["tzoffsetto", {}, "utc-offset", "+02:00"], - ["tzname", {}, "text", "CEST"], - ["dtstart", {}, "date-time", "1970-03-29T02:00:00"], - [ - "rrule", - {}, - "recur", - { freq: "YEARLY", bymonth: 3, byday: "-1SU" }, - ], - ], - [], - ], - [ - "standard", - [ - ["tzoffsetfrom", {}, "utc-offset", "+02:00"], - ["tzoffsetto", {}, "utc-offset", "+01:00"], - ["tzname", {}, "text", "CET"], - ["dtstart", {}, "date-time", "1970-10-25T03:00:00"], - [ - "rrule", - {}, - "recur", - { freq: "YEARLY", bymonth: 10, byday: "-1SU" }, - ], - ], - [], - ], - ], - ], - ], - ]) - ); + it("sends correct API payload for repeat weekly on Thursday and Friday", async () => { + await setupEventPopover(); - // onClose should be called + userEvent.click(await screen.findByText(/repeat weekly/i)); + userEvent.click(screen.getByLabelText("TH")); + userEvent.click(screen.getByLabelText("FR")); + + await expectRRule({ freq: "weekly", byday: ["TH", "FR"] }); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + it("sends correct API payload for repeat weekly on Thursday and Friday and an interval of 3 weeks", async () => { + await setupEventPopover(); + + userEvent.click(await screen.findByText(/repeat weekly/i)); + + userEvent.click(screen.getByLabelText("TH")); + userEvent.click(screen.getByLabelText("FR")); + + 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/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx index 9016a34..c8d0db2 100644 --- a/__test__/features/Events/EventModal.test.tsx +++ b/__test__/features/Events/EventModal.test.tsx @@ -286,7 +286,6 @@ describe("EventPopover", () => { preloadedState.calendars.list["667037022b752d0026472254/cal1"] ); - console.log(receivedPayload.newEvent.start); expect(receivedPayload.newEvent.title).toBe(newEvent.title); expect(receivedPayload.newEvent.description).toBe(newEvent.description); expect( diff --git a/src/components/Event/EventRepeat.tsx b/src/components/Event/EventRepeat.tsx index 7e5843e..5771764 100644 --- a/src/components/Event/EventRepeat.tsx +++ b/src/components/Event/EventRepeat.tsx @@ -193,6 +193,7 @@ export default function RepeatEvent({ On diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index f36b427..e8273fe 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -298,7 +298,20 @@ const CalendarSlice = createSlice({ ) .addCase(deleteEventAsync.fulfilled, (state, action) => { state.pending = false; - delete state.list[action.payload.calId].events[action.payload.eventId]; + const [baseId, recurrenceId] = action.payload.eventId.split("/"); + if (recurrenceId) { + Object.keys(state.list[action.payload.calId].events).forEach( + (element) => { + if (element.split("/")[0] === baseId) { + delete state.list[action.payload.calId].events[element]; + } + } + ); + } else { + delete state.list[action.payload.calId].events[ + action.payload.eventId + ]; + } }) .addCase(getCalendarDetailAsync.pending, (state) => { state.pending = true; From 353d6761b44d0672b90748d592878b83f8e57dcd Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Mon, 8 Sep 2025 09:37:01 +0200 Subject: [PATCH 7/9] [#77] event date modification doesnt duplicate anymore --- src/features/Events/EventDisplay.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index b310542..3509794 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -162,6 +162,16 @@ export default function EventDisplayModal({ color: userPersonnalCalendars[calendarid]?.color, }; + const [baseId, recurrenceId] = event.uid.split("/"); + if (recurrenceId) { + Object.keys(userPersonnalCalendars[calendarid].events).forEach( + (element) => { + if (element.split("/")[0] === baseId) { + dispatch(removeEvent({ calendarUid: calId, eventUid: element })); + } + } + ); + } await dispatch( putEventAsync({ cal: userPersonnalCalendars[calendarid], From 37ab51784ca354eb60fe63f3b6713e367d51e6c4 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Mon, 8 Sep 2025 11:23:24 +0200 Subject: [PATCH 8/9] [#77] added event start day selected by default when repetition is set to weekly --- __test__/components/RepeatEvent.test.tsx | 32 +++++++++++++++++++----- src/components/Event/EventRepeat.tsx | 17 ++++++++++--- src/features/Events/EventDisplay.tsx | 1 + src/features/Events/EventModal.tsx | 1 + 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/__test__/components/RepeatEvent.test.tsx b/__test__/components/RepeatEvent.test.tsx index 065d331..0a89775 100644 --- a/__test__/components/RepeatEvent.test.tsx +++ b/__test__/components/RepeatEvent.test.tsx @@ -65,6 +65,7 @@ function setupRepeatEvent(props?: Partial, state?: any) { renderWithProviders( , @@ -133,7 +134,18 @@ async function expectRRule(expected: any) { spyAPi.mock.calls[0][1]?.body?.toString() ?? ""; const [, , [vevent]] = JSON.parse(receivedPayload); const rrule = vevent[1].find(([name]: any) => name === "rrule"); - expect(rrule[3]).toEqual(expected); + + 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", () => { @@ -221,7 +233,17 @@ describe("Repeat Event API calls", () => { expect(received.newEvent.organizer).toEqual( preloadedState.user.organiserData ); - expect(received.newEvent.repetition).toEqual({ freq: "weekly" }); + 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 ); @@ -280,23 +302,21 @@ describe("Repeat Event API calls", () => { expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); }); - it("sends correct API payload for repeat weekly on Thursday and Friday", async () => { + 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")); - userEvent.click(screen.getByLabelText("FR")); await expectRRule({ freq: "weekly", byday: ["TH", "FR"] }); expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); }); - it("sends correct API payload for repeat weekly on Thursday and Friday and an interval of 3 weeks", async () => { + 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")); - userEvent.click(screen.getByLabelText("FR")); const intervalInput = screen.getByDisplayValue("1"); fireEvent.change(intervalInput, { target: { value: "3" } }); diff --git a/src/components/Event/EventRepeat.tsx b/src/components/Event/EventRepeat.tsx index 5771764..8439955 100644 --- a/src/components/Event/EventRepeat.tsx +++ b/src/components/Event/EventRepeat.tsx @@ -19,15 +19,18 @@ import { RepetitionObject } from "../../features/Events/EventsTypes"; export default function RepeatEvent({ repetition, + eventStart, setRepetition, isOwn = true, }: { repetition: RepetitionObject; + eventStart: Date; setRepetition: Function; isOwn?: boolean; }) { const repetitionValues = ["day", "week", "month", "year"]; const days = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]; + const day = new Date(eventStart); // derive endOption based on repetition const getEndOption = () => { @@ -61,9 +64,17 @@ export default function RepeatEvent({ value={repetition.freq ?? ""} disabled={!isOwn} label="Repetition" - onChange={(e: SelectChangeEvent) => - setRepetition({ ...repetition, freq: e.target.value }) - } + onChange={(e: SelectChangeEvent) => { + if (e.target.value === "weekly") { + setRepetition({ + ...repetition, + freq: e.target.value, + selectedDays: [days[day.getDay() - 1]], + }); + } else { + setRepetition({ ...repetition, freq: e.target.value }); + } + }} > No Repetition Repeat daily diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 3509794..e6c9ccd 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -466,6 +466,7 @@ export default function EventDisplayModal({ <> diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index 4720a34..9b2fbd6 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -284,6 +284,7 @@ function EventPopover({ <> From 6562351a2f0c50eba97be534684c9f507c755c21 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Tue, 9 Sep 2025 11:48:43 +0200 Subject: [PATCH 9/9] [#77] blocked resize and DnD on recurring events --- src/components/Calendar/Calendar.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index e1fec6e..d8a631a 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -14,6 +14,7 @@ import { CalendarEvent } from "../../features/Events/EventsTypes"; import CalendarSelection from "./CalendarSelection"; import { getCalendarDetailAsync, + getEventAsync, putEventAsync, updateEventLocal, } from "../../features/Calendars/CalendarSlice"; @@ -326,11 +327,21 @@ export default function CalendarApp() { setEventDisplayedCalId(info.event.extendedProps.calId); } }} + eventAllow={(dropInfo, draggedEvent) => { + if ( + draggedEvent?.extendedProps.uid && + draggedEvent.extendedProps.uid.split("/")[1] + ) { + return false; + } + return true; + }} eventDrop={(arg) => { const event = calendars[arg.event._def.extendedProps.calId].events[ arg.event._def.extendedProps.uid ]; + const totalDeltaMs = getDeltaInMilliseconds(arg.delta); const originalStart = new Date(event.start); @@ -358,7 +369,9 @@ export default function CalendarApp() { calendars[arg.event._def.extendedProps.calId].events[ arg.event._def.extendedProps.uid ]; - + if (event.uid.split("/")[1]) { + dispatch(getEventAsync(event)); + } const originalStart = new Date(event.start); const computedNewStart = new Date( originalStart.getTime() + getDeltaInMilliseconds(arg.startDelta) @@ -372,7 +385,7 @@ export default function CalendarApp() { start: computedNewStart, end: computedNewEnd, } as CalendarEvent; - console.log(event , newEvent); + console.log(event, newEvent); dispatch( putEventAsync({ cal: calendars[newEvent.calId], newEvent }) );