From ee01e1d027092453c820d41bcb91419ef29a0885 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Tue, 2 Sep 2025 18:14:55 +0200 Subject: [PATCH] [#77] added complex ui for repetition, fixed broken tests due to new feature --- .../components/EventModifications.test.tsx | 21 +-- .../features/Events/EventDisplay.test.tsx | 46 +++-- __test__/features/Events/EventModal.test.tsx | 2 - __test__/features/Events/eventUtils.test.ts | 4 +- src/components/Event/EventRepeat.tsx | 164 ++++++++++++++++++ src/components/Menubar/Menubar.tsx | 3 +- src/features/Calendars/CalendarSlice.ts | 35 +++- src/features/Events/EventApi.ts | 16 +- src/features/Events/EventDisplay.tsx | 38 ++-- src/features/Events/EventDisplayPreview.tsx | 14 +- src/features/Events/EventModal.tsx | 10 +- src/features/Events/EventRepeat.tsx | 36 ---- src/features/Events/EventsTypes.ts | 10 +- src/features/Events/eventUtils.ts | 35 +++- 14 files changed, 344 insertions(+), 90 deletions(-) create mode 100644 src/components/Event/EventRepeat.tsx delete mode 100644 src/features/Events/EventRepeat.tsx diff --git a/__test__/components/EventModifications.test.tsx b/__test__/components/EventModifications.test.tsx index e713c75..8a20947 100644 --- a/__test__/components/EventModifications.test.tsx +++ b/__test__/components/EventModifications.test.tsx @@ -2,7 +2,7 @@ import { CalendarApi } from "@fullcalendar/core"; import { jest } from "@jest/globals"; import { ThunkDispatch } from "@reduxjs/toolkit"; import "@testing-library/jest-dom"; -import { screen } from "@testing-library/react"; +import { act, screen } from "@testing-library/react"; import * as appHooks from "../../src/app/hooks"; import CalendarApp from "../../src/components/Calendar/Calendar"; import { renderWithProviders } from "../utils/Renderwithproviders"; @@ -88,16 +88,17 @@ describe("CalendarApp integration", () => { { timeout: 3000 } ); expect(eventEl).toBeInTheDocument(); + act(() => { + if (calendarApi) { + const fcEvent = calendarApi.getEventById("event1"); + expect(fcEvent?.title).toBe("Test Event"); + const oldEnd = new Date(today.getTime() + 3600000); // +1 hour + const newEnd = new Date(oldEnd.getTime() + 1800000); // +30 min - if (calendarApi) { - const fcEvent = calendarApi.getEventById("event1"); - expect(fcEvent?.title).toBe("Test Event"); - const oldEnd = new Date(today.getTime() + 3600000); // +1 hour - const newEnd = new Date(oldEnd.getTime() + 1800000); // +30 min + fcEvent?.setEnd(newEnd); - fcEvent?.setEnd(newEnd); - - expect(dispatch).toHaveBeenCalled(); - } + expect(dispatch).toHaveBeenCalled(); + } + }); }); }); diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index 869d918..b210ee0 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -1,4 +1,4 @@ -import { screen, fireEvent, waitFor } from "@testing-library/react"; +import { screen, fireEvent, waitFor, act } from "@testing-library/react"; import * as eventThunks from "../../../src/features/Calendars/CalendarSlice"; import { renderWithProviders } from "../../utils/Renderwithproviders"; import EventDisplayModal, { @@ -7,6 +7,7 @@ import EventDisplayModal, { stringToColor, } from "../../../src/features/Events/EventDisplay"; import EventPreviewModal from "../../../src/features/Events/EventDisplayPreview"; + describe("Event Preview Display", () => { const mockOnClose = jest.fn(); const day = new Date(); @@ -915,7 +916,19 @@ describe("Event Full Display", () => { const updatedEvent = spy.mock.calls[0][0].newEvent; expect(updatedEvent.attendee[0].partstat).toBe("DECLINED"); }); - test("toggle Show More reveals extra fields", () => { + 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( { />, preloadedState ); - fireEvent.click(screen.getByText("Show More")); - expect(screen.getByLabelText("Alarm")).toBeInTheDocument(); - expect(screen.getByLabelText("Repetition")).toBeInTheDocument(); - expect(screen.getByLabelText("Visibility")).toBeInTheDocument(); + act(() => { + 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(); + expect(screen.getByLabelText(/Visibility/i)).toBeInTheDocument(); + }); fireEvent.click(screen.getByText("Show Less")); }); - test("can edit title when user is organizer", () => { + it("can edit title when user is organizer", () => { renderWithProviders( { fireEvent.change(titleField, { target: { value: "New Title" } }); expect(screen.getByDisplayValue("New Title")).toBeInTheDocument(); }); - test("calendar select is disabled when not organizer", () => { + it("calendar select is disabled when not organizer", () => { const rsvpState = { ...preloadedState, calendars: { @@ -988,7 +1010,7 @@ describe("Event Full Display", () => { ); expect(screen.getByLabelText("Calendar")).toHaveClass("Mui-disabled"); }); - test("toggle all-day updates end date correctly", () => { + it("toggle all-day updates end date correctly", () => { renderWithProviders( { }); describe("Helper functions", () => { - test("stringToColor generates consistent color", () => { + it("stringToColor generates consistent color", () => { expect(stringToColor("Alice")).toMatch(/^#[0-9a-f]{6}$/); expect(stringToColor("Alice")).toBe(stringToColor("Alice")); }); - test("stringAvatar returns correct props", () => { + it("stringAvatar returns correct props", () => { const result = stringAvatar("Alice"); expect(result.children).toBe("A"); expect(result.sx.bgcolor).toMatch(/^#/); }); - test("InfoRow renders text and link if url is valid", () => { + it("InfoRow renders text and link if url is valid", () => { renderWithProviders( ico} diff --git a/__test__/features/Events/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx index 3ecfba6..bb95e68 100644 --- a/__test__/features/Events/EventModal.test.tsx +++ b/__test__/features/Events/EventModal.test.tsx @@ -247,7 +247,6 @@ describe("EventPopover", () => { uid: "6045c603-11ab-43c5-bc30-0641420bb3a8", description: "Discuss project", location: "Zoom", - repetition: "", organizer: { cn: "test", cal_address: "test@test.com" }, timezone: "Europe/Paris", transp: "OPAQUE", @@ -302,7 +301,6 @@ describe("EventPopover", () => { ).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.end)).split("T")[0]); expect(receivedPayload.newEvent.location).toBe(newEvent.location); 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 ); diff --git a/__test__/features/Events/eventUtils.test.ts b/__test__/features/Events/eventUtils.test.ts index 2431996..145af5e 100644 --- a/__test__/features/Events/eventUtils.test.ts +++ b/__test__/features/Events/eventUtils.test.ts @@ -167,7 +167,7 @@ describe("calendarEventToJCal", () => { allday: false, location: "Room 101", description: "Discuss project roadmap.", - repetition: "WEEKLY", + repetition: { freq: "WEEKLY" }, organizer: { cn: "Alice", cal_address: "alice@example.com", @@ -280,7 +280,7 @@ describe("calendarEventToJCal", () => { allday: true, location: "Room 101", description: "Discuss project roadmap.", - repetition: "WEEKLY", + repetition: { freq: "WEEKLY" }, organizer: { cn: "Alice", cal_address: "alice@example.com", diff --git a/src/components/Event/EventRepeat.tsx b/src/components/Event/EventRepeat.tsx new file mode 100644 index 0000000..433fc2f --- /dev/null +++ b/src/components/Event/EventRepeat.tsx @@ -0,0 +1,164 @@ +import { + FormControl, + InputLabel, + Select, + SelectChangeEvent, + MenuItem, + Box, + Stack, + Paper, + Typography, + TextField, + Checkbox, + List, + ListItem, + FormControlLabel, + FormGroup, + Radio, + RadioGroup, +} from "@mui/material"; +import { useState } from "react"; +import { RepetitionObject } from "../../features/Events/EventsTypes"; + +export default function RepeatEvent({ + repetition, + setRepetition, + isOwn = true, +}: { + repetition: RepetitionObject; + 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] + ); + }; + return ( + + Repetition + + {repetition.freq && ( + + + Interval: + setInterval(Number(e.target.value))} + size="small" + sx={{ width: 80 }} + /> + + + { + repetitionValues[ + repetitionValues.findIndex((el) => el === repetition.freq) + ] + } + + + {repetition.freq === "weekly" && ( + + + On days: + + + {days.map((day) => ( + handleDayChange(day)} + /> + } + label={day} + /> + ))} + + + )} + + + End: + + setEndOption(e.target.value)} + > + } + label="Never" + /> + + } + label={ + + After + setOccurrences(Number(e.target.value))} + sx={{ width: 100 }} + inputProps={{ min: 1 }} + disabled={endOption !== "after"} + /> + occurrences + + } + /> + + } + label={ + + On + setEndDate(e.target.value)} + disabled={endOption !== "on"} + /> + + } + /> + + + + )} + + ); +} diff --git a/src/components/Menubar/Menubar.tsx b/src/components/Menubar/Menubar.tsx index 37b21fb..cc3ee09 100644 --- a/src/components/Menubar/Menubar.tsx +++ b/src/components/Menubar/Menubar.tsx @@ -74,7 +74,7 @@ export function Menubar() { >
{applist.map((prop: AppIconProps) => ( - + ))}
@@ -96,7 +96,6 @@ export function MainTitle() { function AppIcon({ prop }: { prop: AppIconProps }) { return ( ("calendars/getEvent", async (event) => { + const response: CalendarEvent = await getEvent(event); + return { + calId: event.calId, + event: response, + }; +}); + export const moveEventAsync = createAsyncThunk< { calId: string; events: CalendarEvent[] }, // Return type { cal: Calendars; newEvent: CalendarEvent; newURL: string } // Arg type @@ -233,6 +245,24 @@ const CalendarSlice = createSlice({ }); } ) + .addCase( + getEventAsync.fulfilled, + ( + state, + action: PayloadAction<{ calId: string; event: CalendarEvent }> + ) => { + state.pending = false; + if (!state.list[action.payload.calId]) { + state.list[action.payload.calId] = { + id: action.payload.calId, + events: {}, + } as Calendars; + } + + state.list[action.payload.calId].events[action.payload.event.uid] = + action.payload.event; + } + ) .addCase( moveEventAsync.fulfilled, ( @@ -266,6 +296,9 @@ const CalendarSlice = createSlice({ .addCase(getCalendarDetailAsync.pending, (state) => { state.pending = true; }) + .addCase(getEventAsync.pending, (state) => { + state.pending = true; + }) .addCase(getCalendarsListAsync.pending, (state) => { state.pending = true; }) diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index add8daf..734c9f3 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -1,6 +1,20 @@ import { api } from "../../utils/apiUtils"; import { CalendarEvent } from "./EventsTypes"; -import { calendarEventToJCal } from "./eventUtils"; +import { calendarEventToJCal, parseCalendarEvent } from "./eventUtils"; +import ICAL from "ical.js"; + +export async function getEvent(event: CalendarEvent) { + const response = await api.get(`dav${event.URL}`); + const eventData = await response.text(); + const eventical = ICAL.parse(eventData); + const eventjson = parseCalendarEvent( + eventical[2][1][1], + event.color ?? "", + event.calId, + event.URL + ); + return { ...eventjson, ...event }; +} export async function putEvent(event: CalendarEvent) { const response = await api(`dav${event.URL}`, { diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 4bd72cd..f8f4678 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { deleteEventAsync, + getEventAsync, moveEventAsync, putEventAsync, removeEvent, @@ -42,10 +43,10 @@ import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import { userAttendee } from "../User/userDataTypes"; import { TIMEZONES } from "../../utils/timezone-data"; import { Calendars } from "../Calendars/CalendarTypes"; -import { CalendarEvent } from "./EventsTypes"; +import { CalendarEvent, RepetitionObject } from "./EventsTypes"; import { isValidUrl } from "../../utils/apiUtils"; import { formatLocalDateTime } from "./EventModal"; -import RepeatEvent from "./EventRepeat"; +import RepeatEvent from "../../components/Event/EventRepeat"; export default function EventDisplayModal({ eventId, @@ -68,8 +69,8 @@ export default function EventDisplayModal({ const [showAllAttendees, setShowAllAttendees] = useState(false); const [showMore, setShowMore] = useState(false); - const calendars = useAppSelector((state) => - Object.values(state.calendars.list) + const calendars = Object.values( + useAppSelector((state) => state.calendars.list) ); const userPersonnalCalendars: Calendars[] = calendars.filter( @@ -87,13 +88,15 @@ export default function EventDisplayModal({ formatLocalDateTime(new Date(event?.end ?? Date.now())) ); const [allday, setAllDay] = useState(event?.allday); - const [repetition, setRepetition] = useState(event?.repetition ?? ""); + const [repetition, setRepetition] = useState( + event.repetition ?? ({} as RepetitionObject) + ); const [alarm, setAlarm] = useState(""); const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC"); const [timezone, setTimezone] = useState(event?.timezone ?? "UTC"); const [newCalId, setNewCalId] = useState(event.calId); const [calendarid, setCalendarid] = useState( - event?.calId.split("/")[0] === user.userData.openpaasId + calId.split("/")[0] === user.userData.openpaasId ? userPersonnalCalendars.findIndex((cal) => cal.id === calId) : calendars.findIndex((cal) => cal.id === calId) ); @@ -103,7 +106,6 @@ export default function EventDisplayModal({ (a) => a.cal_address !== event?.organizer?.cal_address ) ); - const currentUserAttendee = event?.attendee?.find( (person) => person.cal_address === user.userData.email ); @@ -120,7 +122,7 @@ export default function EventDisplayModal({ if (!event || !calendar) { onClose({}, "backdropClick"); } - }, [event, calendar, onClose]); + }, [open, eventId, dispatch, onClose]); if (!event || !calendar) return null; @@ -187,8 +189,18 @@ export default function EventDisplayModal({ onClose({}, "backdropClick"); }; + const [detailsLoaded, setDetailsLoaded] = useState(false); + + const handleToggleShowMore = async () => { + if (!detailsLoaded) { + await dispatch(getEventAsync(event)); + setDetailsLoaded(true); + } + setShowMore(!showMore); + }; + const calList = - event.calId.split("/")[0] === user.userData.openpaasId + calId.split("/")[0] === user.userData.openpaasId ? Object.keys(userPersonnalCalendars).map((calendar, index) => ( @@ -230,7 +242,7 @@ export default function EventDisplayModal({ - + {/* Title */} ( - + {renderAttendeeBadge(a, idx.toString())} {isOwn && ( is Busy console.log(e.target.value) diff --git a/src/features/Events/EventRepeat.tsx b/src/features/Events/EventRepeat.tsx deleted file mode 100644 index 516e219..0000000 --- a/src/features/Events/EventRepeat.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { - FormControl, - InputLabel, - Select, - SelectChangeEvent, - MenuItem, -} from "@mui/material"; - -export default function RepeatEvent({ - repetition, - setRepetition, - isOwn = true, -}: { - repetition: string; - setRepetition: Function; - isOwn?: boolean; -}) { - return ( - - Repetition - - - ); -} diff --git a/src/features/Events/EventsTypes.ts b/src/features/Events/EventsTypes.ts index 2d005be..eb0a13f 100644 --- a/src/features/Events/EventsTypes.ts +++ b/src/features/Events/EventsTypes.ts @@ -21,5 +21,13 @@ export interface CalendarEvent { error?: string; status?: string; timezone: string; - repetition?: string; + repetition?: RepetitionObject; +} + +export interface RepetitionObject { + freq: string; + interval?: number; + selectedDays?: string[]; + occurrences?: number; + endDate?: string; } diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index b604603..088eb0f 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -81,6 +81,22 @@ export function parseCalendarEvent( break; case "status": event.status = String(value); + break; + case "rrule": + event.repetition = { freq: value.freq.toLowerCase() }; + if (value.byday) { + event.repetition.selectedDays = value.byday; + } + if (value.until) { + event.repetition.selectedDays = value.endDate; + } + if (value.count) { + event.repetition.selectedDays = value.occurrences; + } + if (value.interval) { + event.repetition.interval = value.interval; + } + break; } } if (recurrenceId && event.uid) { @@ -105,7 +121,7 @@ export function calendarEventToJCal(event: CalendarEvent): any[] { const vevent: any[] = [ "vevent", [ - ["uid", {}, "text", event.uid], + ["uid", {}, "text", event.uid.split("/")[0]], ["transp", {}, "text", event.transp ?? "OPAQUE"], [ "dtstart", @@ -150,8 +166,21 @@ export function calendarEventToJCal(event: CalendarEvent): any[] { if (event.description) { vevent[1].push(["description", {}, "text", event.description]); } - if (event.repetition) { - vevent[1].push(["rrule", {}, "recur", { freq: event.repetition }]); + if (event.repetition?.freq) { + const repetitionRule: Record = { freq: event.repetition.freq }; + if (event.repetition.interval) { + repetitionRule["interval"] = event.repetition.interval; + } + if (event.repetition.occurrences) { + repetitionRule["count"] = event.repetition.occurrences; + } + if (event.repetition.endDate) { + repetitionRule["until"] = event.repetition.endDate; + } + if (event.repetition.selectedDays) { + repetitionRule["byday"] = event.repetition.selectedDays; + } + vevent[1].push(["rrule", {}, "recur", repetitionRule]); } event.attendee.forEach((att) => {