From c86db8fa4974f42c7af531fa5c2b0e5f7b42aa8d Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Tue, 7 Oct 2025 18:16:00 +0200 Subject: [PATCH] refactor recurring event modification (#173) --- .../features/Events/EventDisplay.test.tsx | 125 +- .../features/Events/EventRepetition.test.tsx | 1084 +++++++++++++++++ src/components/Calendar/Calendar.tsx | 21 + .../Calendar/handlers/eventHandlers.ts | 102 +- .../hooks/useCalendarEventHandlers.ts | 3 + src/components/Event/EditModeDialog.tsx | 70 ++ src/components/Event/EventDisplayPreview.tsx | 174 ++- src/components/Event/EventRepeat.tsx | 21 +- .../Event/eventHandlers/eventHandlers.ts | 91 ++ src/components/Event/utils/eventUtils.tsx | 28 + src/features/Calendars/CalendarSlice.ts | 38 + src/features/Events/EventApi.ts | 114 +- src/features/Events/EventDisplay.tsx | 167 ++- src/features/Events/EventsTypes.ts | 2 + src/features/Events/eventUtils.ts | 60 +- 15 files changed, 1924 insertions(+), 176 deletions(-) create mode 100644 __test__/features/Events/EventRepetition.test.tsx create mode 100644 src/components/Event/EditModeDialog.tsx create mode 100644 src/components/Event/eventHandlers/eventHandlers.ts diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index d2cb2e0..903f240 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -46,7 +46,7 @@ describe("Event Preview Display", () => { color: "#FF0000", events: { event1: { - id: "event1", + uid: "event1", title: "Test Event", calId: "667037022b752d0026472254/cal1", start: day.toISOString(), @@ -72,7 +72,7 @@ describe("Event Preview Display", () => { ], }, event2: { - id: "event2", + uid: "event2", title: "Test Event", calId: "667037022b752d0026472254/cal1", start: day.toISOString(), @@ -87,7 +87,7 @@ describe("Event Preview Display", () => { color: "#FF0000", events: { event1: { - id: "event1", + uid: "event1", calId: "otherCal/cal", title: "Test Event Other cal", start: day.toISOString(), @@ -358,7 +358,6 @@ describe("Event Preview Display", () => { await waitFor(() => { expect(spy).toHaveBeenCalled(); - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); }); const updatedEvent = spy.mock.calls[0][0].newEvent; @@ -416,7 +415,6 @@ describe("Event Preview Display", () => { await waitFor(() => { expect(spy).toHaveBeenCalled(); - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); }); const updatedEvent = spy.mock.calls[0][0].newEvent; @@ -474,7 +472,6 @@ describe("Event Preview Display", () => { await waitFor(() => { expect(spy).toHaveBeenCalled(); - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); }); const updatedEvent = spy.mock.calls[0][0].newEvent; @@ -507,8 +504,8 @@ describe("Event Preview Display", () => { await waitFor(() => { expect(spy).toHaveBeenCalled(); + expect(screen.getByText("Edit Event")).toBeInTheDocument(); }); - expect(screen.getByText("Edit Event")).toBeInTheDocument(); }); it("properly render message button when MAIL_SPA_URL is not null and event has attendees", () => { (window as any).MAIL_SPA_URL = "test"; @@ -1192,69 +1189,67 @@ describe("Event Full Display", () => { expect(spyRemove).toHaveBeenCalled(); }); + // const spyPut = jest + // .spyOn(eventThunks, "putEventAsync") + // .mockImplementation((payload) => () => Promise.resolve(payload) as any); + // const spyRemove = jest.spyOn(eventThunks, "removeEvent"); - it("removes recurrence instances when saving an edited recurring series", async () => { - const spyPut = jest - .spyOn(eventThunks, "putEventAsync") - .mockImplementation((payload) => () => Promise.resolve(payload) as any); - const spyRemove = jest.spyOn(eventThunks, "removeEvent"); + // const day = new Date(); + // const preloadedRecurrence = { + // ...preloadedState, + // calendars: { + // list: { + // "667037022b752d0026472254/cal1": { + // id: "667037022b752d0026472254/cal1", + // name: "First Calendar", + // color: "#FF0000", + // events: { + // "base/20250101": { + // uid: "base/20250101", + // calId: "667037022b752d0026472254/cal1", + // title: "eventA", + // }, + // "base/20250201": { + // uid: "base/20250201", + // calId: "667037022b752d0026472254/cal1", + // title: "eventB", + // }, + // "base/20250301": { + // uid: "base/20250301", + // title: "Recurring event", + // calId: "667037022b752d0026472254/cal1", + // start: day.toISOString(), + // end: day.toISOString(), + // organizer: { cal_address: "test@test.com" }, + // attendee: [{ cal_address: "test@test.com", cn: "Test" }], + // }, + // }, + // }, + // }, + // pending: false, + // }, + // }; - const day = new Date(); - const preloadedRecurrence = { - ...preloadedState, - calendars: { - list: { - "667037022b752d0026472254/cal1": { - id: "667037022b752d0026472254/cal1", - name: "First Calendar", - color: "#FF0000", - events: { - "base/20250101": { - uid: "base/20250101", - calId: "667037022b752d0026472254/cal1", - title: "eventA", - }, - "base/20250201": { - uid: "base/20250201", - calId: "667037022b752d0026472254/cal1", - title: "eventB", - }, - "base/20250301": { - uid: "base/20250301", - title: "Recurring event", - calId: "667037022b752d0026472254/cal1", - start: day.toISOString(), - end: day.toISOString(), - organizer: { cal_address: "test@test.com" }, - attendee: [{ cal_address: "test@test.com", cn: "Test" }], - }, - }, - }, - }, - pending: false, - }, - }; + // renderWithProviders( + // , + // preloadedRecurrence + // ); - renderWithProviders( - , - preloadedRecurrence - ); + // act(() => fireEvent.click(screen.getByText("Save"))); - act(() => fireEvent.click(screen.getByText("Save"))); + // await waitFor(() => { + // expect(spyPut).toHaveBeenCalled(); + // }); - await waitFor(() => { - expect(spyPut).toHaveBeenCalled(); - }); - - await waitFor(() => { - expect(spyRemove).toHaveBeenCalled(); - }); - }); + // await waitFor(() => { + // expect(spyRemove).toHaveBeenCalled(); + // }); + // }); it("InfoRow renders error style when error prop is true", () => { renderWithProviders(i} text="Bad" error />); diff --git a/__test__/features/Events/EventRepetition.test.tsx b/__test__/features/Events/EventRepetition.test.tsx new file mode 100644 index 0000000..03203a2 --- /dev/null +++ b/__test__/features/Events/EventRepetition.test.tsx @@ -0,0 +1,1084 @@ +import { screen, fireEvent, waitFor } from "@testing-library/react"; +import * as eventThunks from "../../../src/features/Calendars/CalendarSlice"; +import { renderWithProviders } from "../../utils/Renderwithproviders"; +import EventDisplayModal from "../../../src/features/Events/EventDisplay"; +import { EditModeDialog } from "../../../src/components/Event/EditModeDialog"; +import * as EventApi from "../../../src/features/Events/EventApi"; +import { + createEventHandlers, + EventHandlersProps, +} from "../../../src/components/Calendar/handlers/eventHandlers"; +import EventPreviewModal from "../../../src/components/Event/EventDisplayPreview"; + +describe("Recurrence Event Behavior Tests", () => { + const mockOnClose = jest.fn(); + const day = new Date("2025-03-15T10:00:00Z"); + + beforeEach(() => { + jest.clearAllMocks(); + (window as any).MAIL_SPA_URL = null; + }); + + const basePreloadedState = { + 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", + color: "#FF0000", + events: { + "recurring-base/20250315T100000": { + uid: "recurring-base/20250315T100000", + title: "Recurring Event Instance", + calId: "667037022b752d0026472254/cal1", + start: day.toISOString(), + end: new Date("2025-03-15T11:00:00Z").toISOString(), + organizer: { cn: "test", cal_address: "test@test.com" }, + recurrenceId: "20250315T100000", + timezone: "UTC", + URL: "/calendars/667037022b752d0026472254/cal1/recurring-base.ics", + attendee: [ + { + cn: "test", + cal_address: "test@test.com", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + }, + { + cn: "John", + cal_address: "john@test.com", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + }, + ], + }, + "recurring-base/20250322T100000": { + uid: "recurring-base/20250322T100000", + title: "Recurring Event Instance", + calId: "667037022b752d0026472254/cal1", + start: new Date("2025-03-22T10:00:00Z").toISOString(), + end: new Date("2025-03-22T11:00:00Z").toISOString(), + organizer: { cn: "test", cal_address: "test@test.com" }, + recurrenceId: "20250322T100000", + timezone: "UTC", + URL: "/calendars/667037022b752d0026472254/cal1/recurring-base.ics", + attendee: [ + { + cn: "test", + cal_address: "test@test.com", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + }, + ], + }, + }, + }, + }, + pending: false, + templist: {}, + }, + }; + + describe("EditModeDialog Component", () => { + it("renders dialog when type is 'edit'", () => { + const mockSetOpen = jest.fn(); + const mockEventAction = jest.fn(); + + renderWithProviders( + , + basePreloadedState + ); + + expect( + screen.getByText("Update the reccurent event") + ).toBeInTheDocument(); + expect(screen.getByText("This event")).toBeInTheDocument(); + expect(screen.getByText("All the events")).toBeInTheDocument(); + }); + + it("renders dialog when type is 'attendance'", () => { + const mockSetOpen = jest.fn(); + const mockEventAction = jest.fn(); + + renderWithProviders( + , + basePreloadedState + ); + + expect( + screen.getByText("Update the participation status") + ).toBeInTheDocument(); + }); + + it("calls eventAction with 'solo' when solo option is selected and Ok clicked", async () => { + const mockSetOpen = jest.fn(); + const mockEventAction = jest.fn(); + + renderWithProviders( + , + basePreloadedState + ); + + fireEvent.click(screen.getByLabelText("This event")); + fireEvent.click(screen.getByText("Ok")); + + await waitFor(() => { + expect(mockEventAction).toHaveBeenCalledWith("solo"); + expect(mockSetOpen).toHaveBeenCalledWith(null); + }); + }); + + it("calls eventAction with 'all' when all option is selected and Ok clicked", async () => { + const mockSetOpen = jest.fn(); + const mockEventAction = jest.fn(); + + renderWithProviders( + , + basePreloadedState + ); + + fireEvent.click(screen.getByLabelText("All the events")); + fireEvent.click(screen.getByText("Ok")); + + await waitFor(() => { + expect(mockEventAction).toHaveBeenCalledWith("all"); + expect(mockSetOpen).toHaveBeenCalledWith(null); + }); + }); + + it("calls setOpen with null when Cancel is clicked", async () => { + const mockSetOpen = jest.fn(); + const mockEventAction = jest.fn(); + + renderWithProviders( + , + basePreloadedState + ); + + fireEvent.click(screen.getByText("Cancel")); + + await waitFor(() => { + expect(mockSetOpen).toHaveBeenCalledWith(null); + expect(mockEventAction).not.toHaveBeenCalled(); + }); + }); + }); + + describe("EventPreviewModal - Recurring Event Interactions", () => { + it("shows EditModeDialog when editing a recurring event", async () => { + jest.spyOn(eventThunks, "getEventAsync").mockImplementation((payload) => { + return () => + Promise.resolve({ + calId: payload.calId, + event: + basePreloadedState.calendars.list["667037022b752d0026472254/cal1"] + .events["recurring-base/20250315T100000"], + }) as any; + }); + + renderWithProviders( + , + basePreloadedState + ); + + fireEvent.click(screen.getByTestId("EditIcon")); + + await waitFor(() => { + expect( + screen.getByText("Update the reccurent event") + ).toBeInTheDocument(); + }); + }); + + it("shows EditModeDialog when deleting a recurring event", async () => { + renderWithProviders( + , + basePreloadedState + ); + + fireEvent.click(screen.getByTestId("MoreVertIcon")); + fireEvent.click(screen.getByText("Delete event")); + + await waitFor(() => { + expect( + screen.getByText("Update the reccurent event") + ).toBeInTheDocument(); + }); + }); + + it("shows EditModeDialog when RSVP to a recurring event", async () => { + renderWithProviders( + , + basePreloadedState + ); + + fireEvent.click(screen.getByRole("button", { name: "Accept" })); + + await waitFor(() => { + expect( + screen.getByText("Update the participation status") + ).toBeInTheDocument(); + }); + }); + + it("does not show EditModeDialog for non-recurring events", async () => { + const nonRecurringState = { + ...basePreloadedState, + calendars: { + ...basePreloadedState.calendars, + list: { + "667037022b752d0026472254/cal1": { + ...basePreloadedState.calendars.list[ + "667037022b752d0026472254/cal1" + ], + events: { + "single-event": { + uid: "single-event", + title: "Single Event", + calId: "667037022b752d0026472254/cal1", + start: day.toISOString(), + end: new Date("2025-03-15T11:00:00Z").toISOString(), + organizer: { cn: "test", cal_address: "test@test.com" }, + timezone: "UTC", + URL: "/calendars/667037022b752d0026472254/cal1/single-event.ics", + attendee: [], + }, + }, + }, + }, + }, + }; + + const spy = jest + .spyOn(eventThunks, "deleteEventAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + renderWithProviders( + , + nonRecurringState + ); + + fireEvent.click(screen.getByTestId("MoreVertIcon")); + fireEvent.click(screen.getByText("Delete event")); + + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalled(); + }); + + expect( + screen.queryByText("Update the reccurent event") + ).not.toBeInTheDocument(); + }); + }); + + describe("Delete Recurring Event Instance", () => { + it("calls deleteEventInstanceAsync when deleting single instance", async () => { + const spy = jest + .spyOn(eventThunks, "deleteEventInstanceAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + renderWithProviders( + , + basePreloadedState + ); + + fireEvent.click(screen.getByTestId("MoreVertIcon")); + fireEvent.click(screen.getByText("Delete event")); + + await waitFor(() => { + expect( + screen.getByText("Update the reccurent event") + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByLabelText("This event")); + fireEvent.click(screen.getByText("Ok")); + + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + }); + + const receivedPayload = spy.mock.calls[0][0]; + expect(receivedPayload.event.uid).toBe("recurring-base/20250315T100000"); + }); + + it("calls deleteEventAsync when deleting all instances", async () => { + jest.spyOn(EventApi, "getEvent").mockResolvedValue({ + ...basePreloadedState.calendars.list["667037022b752d0026472254/cal1"] + .events["recurring-base/20250315T100000"], + uid: "recurring-base", + } as any); + + const spy = jest + .spyOn(eventThunks, "deleteEventAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + renderWithProviders( + , + basePreloadedState + ); + + fireEvent.click(screen.getByTestId("MoreVertIcon")); + fireEvent.click(screen.getByText("Delete event")); + + await waitFor(() => { + expect( + screen.getByText("Update the reccurent event") + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByLabelText("All the events")); + fireEvent.click(screen.getByText("Ok")); + + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + }); + + const receivedPayload = spy.mock.calls[0][0]; + expect(receivedPayload.eventId).toBe("recurring-base/20250315T100000"); + }); + }); + + describe("RSVP to Recurring Event", () => { + it("calls updateEventInstanceAsync when accepting single instance", async () => { + const spy = jest + .spyOn(eventThunks, "updateEventInstanceAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + renderWithProviders( + , + basePreloadedState + ); + + fireEvent.click(screen.getByRole("button", { name: "Accept" })); + + await waitFor(() => { + expect( + screen.getByText("Update the participation status") + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByLabelText("This event")); + fireEvent.click(screen.getByText("Ok")); + + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + }); + + const updatedEvent = spy.mock.calls[0][0].event; + expect(updatedEvent.attendee[0].partstat).toBe("ACCEPTED"); + }); + + it("calls updateSeriesAsync when accepting all instances", async () => { + const getEventSpy = jest.spyOn(EventApi, "getEvent").mockResolvedValue({ + ...basePreloadedState.calendars.list["667037022b752d0026472254/cal1"] + .events["recurring-base/20250315T100000"], + uid: "recurring-base", + } as any); + + const spy = jest + .spyOn(eventThunks, "updateSeriesAsync") + .mockImplementation((payload) => { + return () => Promise.resolve() as any; + }); + + renderWithProviders( + , + basePreloadedState + ); + + fireEvent.click(screen.getByRole("button", { name: "Accept" })); + + await waitFor(() => { + expect( + screen.getByText("Update the participation status") + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByLabelText("All the events")); + fireEvent.click(screen.getByText("Ok")); + + await waitFor(() => { + expect(getEventSpy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalled(); + }); + + const updatedEvent = spy.mock.calls[0][0].event; + expect(updatedEvent.attendee[0].partstat).toBe("ACCEPTED"); + }); + }); + + describe("Edit Recurring Event in Full Display", () => { + it("renders event with recurrenceId in URL", () => { + renderWithProviders( + , + basePreloadedState + ); + + expect( + screen.getByDisplayValue("Recurring Event Instance") + ).toBeInTheDocument(); + }); + + it("calls updateEventInstanceAsync when saving single instance with typeOfAction='solo'", async () => { + const spy = jest + .spyOn(eventThunks, "updateEventInstanceAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + renderWithProviders( + , + basePreloadedState + ); + + const titleField = screen.getByLabelText("Title"); + fireEvent.change(titleField, { + target: { value: "Updated Single Instance" }, + }); + + fireEvent.click(screen.getByText("Save")); + + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + }); + + const updatedEvent = spy.mock.calls[0][0].event; + expect(updatedEvent.title).toBe("Updated Single Instance"); + expect(updatedEvent.recurrenceId).toBe("20250315T100000"); + }); + + it("calls updateSeriesAsync when saving all instances with typeOfAction='all'", async () => { + const getEventSpy = jest.spyOn(EventApi, "getEvent").mockResolvedValue({ + ...basePreloadedState.calendars.list["667037022b752d0026472254/cal1"] + .events["recurring-base/20250315T100000"], + uid: "recurring-base", + } as any); + + const spy = jest + .spyOn(eventThunks, "updateSeriesAsync") + .mockImplementation((payload) => { + return () => Promise.resolve() as any; + }); + + renderWithProviders( + , + basePreloadedState + ); + + await waitFor(() => { + expect(getEventSpy).toHaveBeenCalled(); + }); + + const titleField = screen.getByLabelText("Title"); + fireEvent.change(titleField, { + target: { value: "Updated All Instances" }, + }); + + fireEvent.click(screen.getByText("Save")); + + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + }); + + const updatedEvent = spy.mock.calls[0][0].event; + expect(updatedEvent.title).toBe("Updated All Instances"); + }); + + it("disables repetition editing when typeOfAction='solo'", () => { + renderWithProviders( + , + basePreloadedState + ); + + fireEvent.click(screen.getByText("Show More")); + + expect(screen.getByText(/repeat every/i)).toBeInTheDocument(); + expect(screen.getByText(/end:/i)).toBeInTheDocument(); + + const frequencySelect = screen.getByRole("radio", { + name: /after \d occurrences/i, + }); + expect(frequencySelect).toBeDisabled(); + }); + + it("fetches master event data when typeOfAction='all'", async () => { + const getEventSpy = jest.spyOn(EventApi, "getEvent").mockResolvedValue({ + ...basePreloadedState.calendars.list["667037022b752d0026472254/cal1"] + .events["recurring-base/20250315T100000"], + uid: "recurring-base", + title: "Master Event Title", + description: "Master Description", + } as any); + + renderWithProviders( + , + basePreloadedState + ); + + await waitFor(() => { + expect(getEventSpy).toHaveBeenCalled(); + expect( + screen.getByDisplayValue("Master Event Title") + ).toBeInTheDocument(); + }); + }); + }); + + describe("Event Drag and Drop - Recurring Events", () => { + it("shows EditModeDialog when dragging recurring event", () => { + const mockDispatch = jest.fn(); + const mockSetSelectedEvent = jest.fn(); + const mockSetOpenEditModePopup = jest.fn(); + const mockSetAfterChoiceFunc = jest.fn(); + + const eventHandlers = createEventHandlers({ + setSelectedRange: jest.fn(), + setOpenEventModal: jest.fn(), + setTempEvent: jest.fn(), + setOpenEventDisplay: jest.fn(), + dispatch: mockDispatch, + calendarRange: { start: new Date(), end: new Date() }, + setEventDisplayedId: jest.fn(), + setEventDisplayedCalId: jest.fn(), + setEventDisplayedTemp: jest.fn(), + calendars: basePreloadedState.calendars.list, + setSelectedEvent: mockSetSelectedEvent, + setAfterChoiceFunc: mockSetAfterChoiceFunc, + setOpenEditModePopup: mockSetOpenEditModePopup, + } as unknown as EventHandlersProps); + + const mockArg = { + event: { + _def: { + extendedProps: { + uid: "recurring-base/20250315T100000", + calId: "667037022b752d0026472254/cal1", + }, + }, + }, + delta: { years: 0, months: 0, days: 1, milliseconds: 0 }, + }; + + eventHandlers.handleEventDrop(mockArg); + + expect(mockSetSelectedEvent).toHaveBeenCalled(); + expect(mockSetOpenEditModePopup).toHaveBeenCalledWith("edit"); + expect(mockSetAfterChoiceFunc).toHaveBeenCalled(); + }); + + it("directly updates non-recurring event on drag", () => { + const mockDispatch = jest.fn(); + const nonRecurringState = { + "667037022b752d0026472254/cal1": { + ...basePreloadedState.calendars.list["667037022b752d0026472254/cal1"], + events: { + "single-event": { + uid: "single-event", + title: "Single Event", + calId: "667037022b752d0026472254/cal1", + start: day.toISOString(), + end: new Date("2025-03-15T11:00:00Z").toISOString(), + timezone: "UTC", + }, + }, + }, + }; + + const eventHandlers = createEventHandlers({ + setSelectedRange: jest.fn(), + setOpenEventModal: jest.fn(), + setTempEvent: jest.fn(), + setOpenEventDisplay: jest.fn(), + dispatch: mockDispatch, + calendarRange: { start: new Date(), end: new Date() }, + setEventDisplayedId: jest.fn(), + setEventDisplayedCalId: jest.fn(), + setEventDisplayedTemp: jest.fn(), + calendars: nonRecurringState, + setSelectedEvent: jest.fn(), + setAfterChoiceFunc: jest.fn(), + setOpenEditModePopup: jest.fn(), + } as unknown as EventHandlersProps); + + const mockArg = { + event: { + _def: { + extendedProps: { + uid: "single-event", + calId: "667037022b752d0026472254/cal1", + }, + }, + }, + delta: { years: 0, months: 0, days: 1, milliseconds: 0 }, + }; + + eventHandlers.handleEventDrop(mockArg); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: expect.stringContaining("updateEventLocal"), + }) + ); + }); + }); + + describe("Event Resize - Recurring Events", () => { + it("shows EditModeDialog when resizing recurring event", () => { + const mockDispatch = jest.fn(); + const mockSetSelectedEvent = jest.fn(); + const mockSetOpenEditModePopup = jest.fn(); + const mockSetAfterChoiceFunc = jest.fn(); + + const eventHandlers = createEventHandlers({ + setSelectedRange: jest.fn(), + setOpenEventModal: jest.fn(), + setTempEvent: jest.fn(), + setOpenEventDisplay: jest.fn(), + dispatch: mockDispatch, + calendarRange: { start: new Date(), end: new Date() }, + setEventDisplayedId: jest.fn(), + setEventDisplayedCalId: jest.fn(), + setEventDisplayedTemp: jest.fn(), + calendars: basePreloadedState.calendars.list, + setSelectedEvent: mockSetSelectedEvent, + setAfterChoiceFunc: mockSetAfterChoiceFunc, + setOpenEditModePopup: mockSetOpenEditModePopup, + } as unknown as EventHandlersProps); + + const mockArg = { + event: { + _def: { + extendedProps: { + uid: "recurring-base/20250315T100000", + calId: "667037022b752d0026472254/cal1", + }, + }, + }, + startDelta: { years: 0, months: 0, days: 0, milliseconds: 0 }, + endDelta: { years: 0, months: 0, days: 0, milliseconds: 3600000 }, // 1 hour + }; + + eventHandlers.handleEventResize(mockArg); + + expect(mockSetSelectedEvent).toHaveBeenCalled(); + expect(mockSetOpenEditModePopup).toHaveBeenCalledWith("edit"); + expect(mockSetAfterChoiceFunc).toHaveBeenCalled(); + }); + }); + + describe("RepeatEvent Component - Recurrence Editing", () => { + it("disables repetition fields when isOwn is false", () => { + renderWithProviders( + , + { + ...basePreloadedState, + calendars: { + ...basePreloadedState.calendars, + list: { + "otherCal/cal": { + id: "otherCal/cal", + name: "Other Calendar", + color: "#00FF00", + events: { + "recurring-base/20250315T100000": { + ...basePreloadedState.calendars.list[ + "667037022b752d0026472254/cal1" + ].events["recurring-base/20250315T100000"], + calId: "otherCal/cal", + organizer: { cn: "other", cal_address: "other@test.com" }, + }, + }, + }, + }, + }, + } + ); + + fireEvent.click(screen.getByText("Show More")); + + // Repetition section should not be visible for non-owner + expect(screen.queryByLabelText(/Repetition/i)).not.toBeInTheDocument(); + }); + }); + + describe("handleRSVP function", () => { + it("calls putEventAsync for non-recurring events", async () => { + const mockDispatch = jest.fn(); + const mockOnClose = jest.fn(); + + const { + handleRSVP, + } = require("../../../src/components/Event/eventHandlers/eventHandlers"); + + jest.spyOn(eventThunks, "putEventAsync").mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + const nonRecurringEvent = { + uid: "single-event", + title: "Single Event", + calId: "667037022b752d0026472254/cal1", + start: day.toISOString(), + end: new Date("2025-03-15T11:00:00Z").toISOString(), + organizer: { cn: "test", cal_address: "test@test.com" }, + attendee: [ + { + cn: "test", + cal_address: "test@test.com", + partstat: "NEEDS-ACTION", + }, + ], + }; + + await handleRSVP( + mockDispatch, + basePreloadedState.calendars.list["667037022b752d0026472254/cal1"], + basePreloadedState.user, + nonRecurringEvent, + "ACCEPTED", + mockOnClose + ); + + expect(mockDispatch).toHaveBeenCalled(); + }); + }); + + describe("handleDelete function", () => { + it("calls deleteEventAsync for non-recurring events", () => { + const mockDispatch = jest.fn(); + const mockOnClose = jest.fn(); + + const { + handleDelete, + } = require("../../../src/components/Event/eventHandlers/eventHandlers"); + + jest + .spyOn(eventThunks, "deleteEventAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + const nonRecurringEvent = { + uid: "single-event", + title: "Single Event", + calId: "667037022b752d0026472254/cal1", + URL: "/calendars/667037022b752d0026472254/cal1/single-event.ics", + }; + + handleDelete( + false, // isRecurring + undefined, + mockOnClose, + mockDispatch, + basePreloadedState.calendars.list["667037022b752d0026472254/cal1"], + nonRecurringEvent, + "667037022b752d0026472254/cal1", + "single-event" + ); + + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + expect(mockDispatch).toHaveBeenCalled(); + }); + + it("calls deleteEventInstanceAsync when deleting solo recurring event", () => { + const mockDispatch = jest.fn(); + const mockOnClose = jest.fn(); + + const { + handleDelete, + } = require("../../../src/components/Event/eventHandlers/eventHandlers"); + + jest + .spyOn(eventThunks, "deleteEventInstanceAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + handleDelete( + true, // isRecurring + "solo", + mockOnClose, + mockDispatch, + basePreloadedState.calendars.list["667037022b752d0026472254/cal1"], + basePreloadedState.calendars.list["667037022b752d0026472254/cal1"] + .events["recurring-base/20250315T100000"], + "667037022b752d0026472254/cal1", + "recurring-base/20250315T100000" + ); + + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + expect(mockDispatch).toHaveBeenCalled(); + }); + + it("calls deleteEventAsync when deleting all recurring events", () => { + const mockDispatch = jest.fn(); + const mockOnClose = jest.fn(); + + const { + handleDelete, + } = require("../../../src/components/Event/eventHandlers/eventHandlers"); + + jest + .spyOn(eventThunks, "deleteEventAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + handleDelete( + true, // isRecurring + "all", + mockOnClose, + mockDispatch, + basePreloadedState.calendars.list["667037022b752d0026472254/cal1"], + basePreloadedState.calendars.list["667037022b752d0026472254/cal1"] + .events["recurring-base/20250315T100000"], + "667037022b752d0026472254/cal1", + "recurring-base/20250315T100000" + ); + + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + expect(mockDispatch).toHaveBeenCalled(); + }); + }); + + describe("Calendar Integration - EditModeDialog Flow", () => { + it("passes correct eventId when editing all instances from preview", async () => { + const getEventSpy = jest + .spyOn(eventThunks, "getEventAsync") + .mockImplementation((payload) => { + return () => + Promise.resolve({ + calId: payload.calId, + event: + basePreloadedState.calendars.list[ + "667037022b752d0026472254/cal1" + ].events["recurring-base/20250315T100000"], + }) as any; + }); + + renderWithProviders( + , + basePreloadedState + ); + + // Click edit + fireEvent.click(screen.getByTestId("EditIcon")); + + await waitFor(() => { + expect( + screen.getByText("Update the reccurent event") + ).toBeInTheDocument(); + }); + + // Select "All the events" + fireEvent.click(screen.getByLabelText("All the events")); + fireEvent.click(screen.getByText("Ok")); + + await waitFor(() => { + expect(getEventSpy).toHaveBeenCalled(); + // Full display modal should open with the first instance of the series + expect(screen.getByText("Edit Event")).toBeInTheDocument(); + }); + }); + }); + + describe("Event URL handling for recurring events", () => { + it("uses base ID for event URL when moving recurring event", async () => { + const moveEventSpy = jest + .spyOn(eventThunks, "moveEventAsync") + .mockImplementation((payload) => { + return () => + Promise.resolve({ calId: payload.cal.id, events: [] }) as any; + }); + + const twoCalState = { + ...basePreloadedState, + calendars: { + ...basePreloadedState.calendars, + list: { + ...basePreloadedState.calendars.list, + "667037022b752d0026472254/cal2": { + id: "667037022b752d0026472254/cal2", + name: "Calendar 2", + color: "#00FF00", + events: {}, + }, + }, + }, + }; + + renderWithProviders( + , + twoCalState + ); + + // Change calendar + fireEvent.mouseDown(screen.getByLabelText("Calendar")); + const option = await screen.findByText("Calendar 2"); + fireEvent.click(option); + + fireEvent.click(screen.getByText("Save")); + + await waitFor(() => { + expect(moveEventSpy).toHaveBeenCalled(); + }); + + const movePayload = moveEventSpy.mock.calls[0][0]; + // Should use base ID, not full uid with recurrence ID + expect(movePayload.newURL).toContain("recurring-base.ics"); + expect(movePayload.newURL).not.toContain("/20250315T100000"); + }); + }); +}); diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index 502e632..5eecc70 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -33,6 +33,7 @@ import { } from "./utils/calendarUtils"; import { useCalendarEventHandlers } from "./hooks/useCalendarEventHandlers"; import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers"; +import { EditModeDialog } from "../Event/EditModeDialog"; interface CalendarAppProps { calendarRef: React.RefObject; @@ -166,6 +167,14 @@ export default function CalendarApp({ const [eventDisplayedId, setEventDisplayedId] = useState(""); const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false); const [eventDisplayedCalId, setEventDisplayedCalId] = useState(""); + const [openEditModePopup, setOpenEditModePopup] = useState( + null + ); + const [, setTypeOfAction] = useState<"solo" | "all" | undefined>(undefined); + const [afterChoiceFunc, setAfterChoiceFunc] = useState(); + const [selectedEvent, setSelectedEvent] = useState( + {} as CalendarEvent + ); const [selectedRange, setSelectedRange] = useState( null ); @@ -188,6 +197,9 @@ export default function CalendarApp({ setEventDisplayedCalId, setEventDisplayedTemp, calendars, + setSelectedEvent, + setAfterChoiceFunc, + setOpenEditModePopup, }); // View handlers @@ -422,6 +434,15 @@ export default function CalendarApp({ calendarRef={calendarRef} event={tempEvent} /> + { + setTypeOfAction(type); + afterChoiceFunc && afterChoiceFunc(type); + }} + /> {openEventDisplay && eventDisplayedId && eventDisplayedCalId && ( void; @@ -24,6 +27,9 @@ export interface EventHandlersProps { setEventDisplayedCalId: (id: string) => void; setEventDisplayedTemp: (temp: boolean) => void; calendars: Record; + setSelectedEvent: (event: CalendarEvent) => void; + setAfterChoiceFunc: (func: Function) => void; + setOpenEditModePopup: (open: string) => void; } export const createEventHandlers = (props: EventHandlersProps) => { @@ -40,6 +46,9 @@ export const createEventHandlers = (props: EventHandlersProps) => { setEventDisplayedCalId, setEventDisplayedTemp, calendars, + setSelectedEvent, + setAfterChoiceFunc, + setOpenEditModePopup, } = props; const handleDateSelect = (selectInfo: DateSelectArg) => { @@ -113,12 +122,6 @@ export const createEventHandlers = (props: EventHandlersProps) => { }; const handleEventAllow = (dropInfo: any, draggedEvent: any) => { - if ( - draggedEvent?.extendedProps.uid && - draggedEvent.extendedProps.uid.split("/")[1] - ) { - return false; - } return true; }; @@ -131,7 +134,9 @@ export const createEventHandlers = (props: EventHandlersProps) => { calendars[arg.event._def.extendedProps.calId].events[ arg.event._def.extendedProps.uid ]; + const calendar = calendars[arg.event._def.extendedProps.calId]; + const isRecurring = event.uid.includes("/"); const totalDeltaMs = getDeltaInMilliseconds(arg.delta); const originalStart = new Date(event.start); @@ -143,8 +148,43 @@ export const createEventHandlers = (props: EventHandlersProps) => { start: computedNewStart.toISOString(), end: computedNewEnd.toISOString(), } as CalendarEvent; - dispatch(updateEventLocal({ calId: newEvent.calId, event: newEvent })); - dispatch(putEventAsync({ cal: calendars[newEvent.calId], newEvent })); + + if (isRecurring) { + setSelectedEvent(event); + setOpenEditModePopup("edit"); + setAfterChoiceFunc( + () => async (typeOfAction: "solo" | "all" | undefined) => { + if (typeOfAction === "solo") { + dispatch( + updateEventInstanceAsync({ cal: calendar, event: newEvent }) + ); + dispatch( + updateEventLocal({ calId: newEvent.calId, event: newEvent }) + ); + } else if (typeOfAction === "all") { + const master = await getEvent(newEvent, true); + await dispatch( + updateSeriesAsync({ + cal: calendar, + event: { + ...master, + start: computedNewStart.toISOString(), + end: computedNewEnd.toISOString(), + }, + }) + ); + await refreshCalendars( + dispatch, + Object.values(calendars), + calendarRange + ); + } + } + ); + } else { + dispatch(updateEventLocal({ calId: newEvent.calId, event: newEvent })); + dispatch(putEventAsync({ cal: calendars[newEvent.calId], newEvent })); + } }; const handleEventResize = (arg: any) => { @@ -156,9 +196,10 @@ export const createEventHandlers = (props: EventHandlersProps) => { calendars[arg.event._def.extendedProps.calId].events[ arg.event._def.extendedProps.uid ]; - if (event.uid.split("/")[1]) { - dispatch(getEventAsync(event)); - } + const calendar = calendars[arg.event._def.extendedProps.calId]; + + const isRecurring = event.uid.includes("/"); + const originalStart = new Date(event.start); const computedNewStart = new Date( originalStart.getTime() + getDeltaInMilliseconds(arg.startDelta) @@ -173,7 +214,42 @@ export const createEventHandlers = (props: EventHandlersProps) => { end: computedNewEnd.toISOString(), } as CalendarEvent; - dispatch(putEventAsync({ cal: calendars[newEvent.calId], newEvent })); + if (isRecurring) { + setSelectedEvent(event); + setOpenEditModePopup("edit"); + setAfterChoiceFunc( + () => async (typeOfAction: "solo" | "all" | undefined) => { + if (typeOfAction === "solo") { + dispatch( + updateEventInstanceAsync({ cal: calendar, event: newEvent }) + ); + dispatch( + updateEventLocal({ calId: newEvent.calId, event: newEvent }) + ); + } else if (typeOfAction === "all") { + const master = await getEvent(newEvent, true); + + await dispatch( + updateSeriesAsync({ + cal: calendar, + event: { + ...master, + start: computedNewStart.toISOString(), + end: computedNewEnd.toISOString(), + }, + }) + ); + await refreshCalendars( + dispatch, + Object.values(calendars), + calendarRange + ); + } + } + ); + } else { + dispatch(putEventAsync({ cal: calendars[newEvent.calId], newEvent })); + } }; return { diff --git a/src/components/Calendar/hooks/useCalendarEventHandlers.ts b/src/components/Calendar/hooks/useCalendarEventHandlers.ts index 8209cbe..eb52333 100644 --- a/src/components/Calendar/hooks/useCalendarEventHandlers.ts +++ b/src/components/Calendar/hooks/useCalendarEventHandlers.ts @@ -37,6 +37,9 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => { handleEventDrop: useCallback(eventHandlers.handleEventDrop, [ props.calendars, props.dispatch, + props.setSelectedEvent, + props.setOpenEditModePopup, + props.setAfterChoiceFunc, ]), handleEventResize: useCallback(eventHandlers.handleEventResize, [ props.calendars, diff --git a/src/components/Event/EditModeDialog.tsx b/src/components/Event/EditModeDialog.tsx new file mode 100644 index 0000000..d45fe48 --- /dev/null +++ b/src/components/Event/EditModeDialog.tsx @@ -0,0 +1,70 @@ +import { + Button, + ButtonGroup, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Radio, + RadioGroup, +} from "@mui/material"; +import { CalendarEvent } from "../../features/Events/EventsTypes"; +import { useState } from "react"; + +export function EditModeDialog({ + type, + setOpen, + event, + eventAction, +}: { + type: string | null; + setOpen: (e: string | null) => void; + event: CalendarEvent; + eventAction: (type: "solo" | "all" | undefined) => void; +}) { + const [typeOfAction, setTypeOfAction] = useState<"solo" | "all" | undefined>( + "solo" + ); + const handleEvent = async () => { + eventAction(typeOfAction); + handleClose(); + }; + const handleClose = () => { + setOpen(null); + setTypeOfAction("solo"); + }; + return ( + + + {type === "edit" && "Update the reccurent event"} + {type === "attendance" && "Update the participation status"} + + + + setTypeOfAction(e.target.value as "solo" | "all" | undefined) + } + > + } + label="This event" + /> + } + label="All the events" + /> + + + + + + + + + + ); +} diff --git a/src/components/Event/EventDisplayPreview.tsx b/src/components/Event/EventDisplayPreview.tsx index 8de750b..e0474ee 100644 --- a/src/components/Event/EventDisplayPreview.tsx +++ b/src/components/Event/EventDisplayPreview.tsx @@ -27,12 +27,13 @@ import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { deleteEventAsync, getEventAsync, - putEventAsync, } from "../../features/Calendars/CalendarSlice"; import { dlEvent } from "../../features/Events/EventApi"; import EventDisplayModal from "../../features/Events/EventDisplay"; import { ResponsiveDialog } from "../Dialog"; +import { EditModeDialog } from "./EditModeDialog"; import EventDuplication from "./EventDuplicate"; +import { handleDelete, handleRSVP } from "./eventHandlers/eventHandlers"; import { InfoRow } from "./InfoRow"; import { renderAttendeeBadge } from "./utils/eventUtils"; export default function EventPreviewModal({ @@ -50,13 +51,26 @@ export default function EventPreviewModal({ }) { const dispatch = useAppDispatch(); const calendars = useAppSelector((state) => state.calendars); + const calendarList = Object.values( + useAppSelector((state) => state.calendars.list) + ); const calendar = tempEvent ? calendars.templist[calId] : calendars.list[calId]; const event = calendar.events[eventId]; const user = useAppSelector((state) => state.user); + + const isRecurring = event?.uid?.includes("/"); const [showAllAttendees, setShowAllAttendees] = useState(false); const [openFullDisplay, setOpenFullDisplay] = useState(false); + const [openEditModePopup, setOpenEditModePopup] = useState( + null + ); + const [typeOfAction, setTypeOfAction] = useState<"solo" | "all" | undefined>( + undefined + ); + const [afterChoiceFunc, setAfterChoiceFunc] = useState(); + const [toggleActionMenu, setToggleActionMenu] = useState( null ); @@ -89,18 +103,6 @@ export default function EventPreviewModal({ (a) => a.cal_address === event.organizer?.cal_address ); - function handleRSVP(rsvp: string) { - const newEvent = { - ...event, - attendee: event.attendee?.map((a) => - a.cal_address === user.userData.email ? { ...a, partstat: rsvp } : a - ), - }; - - dispatch(putEventAsync({ cal: calendar, newEvent })); - onClose({}, "backdropClick"); - } - return ( <> { - setOpenFullDisplay(!openFullDisplay); - await dispatch(getEventAsync(event)); + if (isRecurring) { + setAfterChoiceFunc(() => async () => { + await dispatch(getEventAsync(event)); + setOpenFullDisplay(!openFullDisplay); + }); + setOpenEditModePopup("edit"); + } else { + await dispatch(getEventAsync(event)); + setOpenFullDisplay(!openFullDisplay); + } }} > @@ -173,14 +183,32 @@ export default function EventPreviewModal({ {user.userData.email === event.organizer?.cal_address && ( { - onClose({}, "backdropClick"); - dispatch( - deleteEventAsync({ - calId, - eventId, - eventURL: event.URL, - }) - ); + if (isRecurring) { + setAfterChoiceFunc( + () => + (typeOfAction?: "solo" | "all" | undefined) => + handleDelete( + isRecurring, + typeOfAction, + onClose, + dispatch, + calendar, + event, + calId, + eventId + ) + ); + setOpenEditModePopup("edit"); + } else { + onClose({}, "backdropClick"); + dispatch( + deleteEventAsync({ + calId, + eventId, + eventURL: event.URL, + }) + ); + } }} > Delete event @@ -216,10 +244,7 @@ export default function EventPreviewModal({ } actions={ <> - {currentUserAttendee && - event.uid?.split("/")[1] && - "event is a reccuring event, the interraction between acceptance and recurrence is broken and to avoid loosing reccuring events by accepting or refusing them this feature isn't available for now"} - {currentUserAttendee && !event.uid?.split("/")[1] && ( + {currentUserAttendee && ( Attending? @@ -229,7 +254,34 @@ export default function EventPreviewModal({ ? "success" : "primary" } - onClick={() => handleRSVP("ACCEPTED")} + onClick={() => { + if (isRecurring) { + setAfterChoiceFunc( + () => (type: string) => + handleRSVP( + dispatch, + calendar, + user, + event, + "ACCEPTED", + + onClose, + type, + calendarList + ) + ); + setOpenEditModePopup("attendance"); + } else { + handleRSVP( + dispatch, + calendar, + user, + event, + "ACCEPTED", + onClose + ); + } + }} > Accept @@ -239,7 +291,33 @@ export default function EventPreviewModal({ ? "warning" : "primary" } - onClick={() => handleRSVP("TENTATIVE")} + onClick={() => { + if (isRecurring) { + setAfterChoiceFunc( + () => (type: string) => + handleRSVP( + dispatch, + calendar, + user, + event, + "TENTATIVE", + onClose, + type, + calendarList + ) + ); + setOpenEditModePopup("attendance"); + } else { + handleRSVP( + dispatch, + calendar, + user, + event, + "TENTATIVE", + onClose + ); + } + }} > Maybe @@ -249,7 +327,33 @@ export default function EventPreviewModal({ ? "error" : "primary" } - onClick={() => handleRSVP("DECLINED")} + onClick={() => { + if (isRecurring) { + setAfterChoiceFunc( + () => (type: string) => + handleRSVP( + dispatch, + calendar, + user, + event, + "DECLINED", + onClose, + type, + calendarList + ) + ); + setOpenEditModePopup("attendance"); + } else { + handleRSVP( + dispatch, + calendar, + user, + event, + "DECLINED", + onClose + ); + } + }} > Decline @@ -387,11 +491,21 @@ export default function EventPreviewModal({ )} + { + setTypeOfAction(type); + afterChoiceFunc && afterChoiceFunc(type); + }} + /> setOpenFullDisplay(false)} eventId={eventId} calId={calId} + typeOfAction={typeOfAction} /> ); diff --git a/src/components/Event/EventRepeat.tsx b/src/components/Event/EventRepeat.tsx index 0b19074..1946ae2 100644 --- a/src/components/Event/EventRepeat.tsx +++ b/src/components/Event/EventRepeat.tsx @@ -29,7 +29,6 @@ export default function RepeatEvent({ }) { const days = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]; const day = new Date(eventStart); - // derive endOption based on repetition const getEndOption = () => { if (repetition.occurrences && repetition.occurrences >= 0) return "after"; @@ -65,6 +64,7 @@ export default function RepeatEvent({ setRepetition({ ...repetition, @@ -78,6 +78,7 @@ export default function RepeatEvent({ { - onClose({}, "backdropClick"); - dispatch( - deleteEventAsync({ calId, eventId, eventURL: event.URL }) - ); - }} + onClick={() => + handleDelete( + isRecurring, + typeOfAction, + onClose, + dispatch, + calendar, + event, + calId, + eventId + ) + } > diff --git a/src/features/Events/EventsTypes.ts b/src/features/Events/EventsTypes.ts index 42263b0..bdb3ad1 100644 --- a/src/features/Events/EventsTypes.ts +++ b/src/features/Events/EventsTypes.ts @@ -4,6 +4,7 @@ export interface CalendarEvent { URL: string; calId: string; uid: string; + recurrenceId?: string; transp?: string; start: string; // ISO date string end?: string; @@ -23,6 +24,7 @@ export interface CalendarEvent { timezone: string; repetition?: RepetitionObject; alarm?: AlarmObject; + exdates?: string[]; } export interface RepetitionObject { diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index 6415a65..fae8203 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -79,6 +79,10 @@ export function parseCalendarEvent( case "recurrence-id": recurrenceId = value; break; + case "exdate": + if (!event.exdates) event.exdates = []; + event.exdates.push(value); + break; case "status": event.status = String(value); break; @@ -101,6 +105,7 @@ export function parseCalendarEvent( } if (recurrenceId && event.uid) { event.uid = `${event.uid}/${recurrenceId}`; + event.recurrenceId = recurrenceId; } if (valarm) { @@ -135,6 +140,36 @@ export function calendarEventToJCal( ): any[] { const tzid = event.timezone; // Fallback to UTC if no timezone provided + const vevent: any[] = makeVevent(event, tzid, calOwnerEmail); + + const timezoneData = TIMEZONES.zones[event.timezone]; + const vtimezone = makeTimezone(timezoneData, event); + + return ["vcalendar", [], [vevent, vtimezone.component.jCal]]; +} + +export function makeTimezone( + timezoneData: { ics: string; latitude: string; longitude: string }, + event: CalendarEvent +) { + if (!timezoneData) { + return new ICAL.Timezone({ + component: TIMEZONES.zones["Etc/UTC"].ics, + tzid: "Etc/UTC", + }); + } + return new ICAL.Timezone({ + component: timezoneData.ics, + tzid: event.timezone, + }); +} + +export function makeVevent( + event: CalendarEvent, + tzid: string, + calOwnerEmail: string | undefined, + isMasterEvent?: boolean +) { const vevent: any[] = [ "vevent", [ @@ -154,6 +189,7 @@ export function calendarEventToJCal( event.x_openpass_videoconference ?? null, ], ["summary", {}, "text", event.title ?? ""], + ["dstamp", { tzid }, "date-time", formatDateToICal(new Date(), false)], ], ]; if (event.alarm?.trigger) { @@ -201,6 +237,9 @@ export function calendarEventToJCal( if (event.location) { vevent[1].push(["location", {}, "text", event.location]); } + if (event.recurrenceId && !isMasterEvent) { + vevent[1].push(["recurrence-id", {}, "date-time", event.recurrenceId]); + } if (event.description) { vevent[1].push(["description", {}, "text", event.description]); } @@ -242,21 +281,20 @@ export function calendarEventToJCal( ]); }); - const timezoneData = TIMEZONES.zones[event.timezone]; - if (!timezoneData) { - const vtimezone = new ICAL.Timezone({ - component: TIMEZONES.zones["Etc/UTC"].ics, - tzid: "Etc/UTC", + if (event.exdates && event.exdates.length > 0) { + event.exdates.forEach((ex) => { + vevent[1].push([ + "exdate", + { tzid }, + "date-time", + formatDateToICal(new Date(ex), false), + ]); }); - return ["vcalendar", [], [vevent, vtimezone.component.jCal]]; } - const vtimezone = new ICAL.Timezone({ - component: timezoneData.ics, - tzid: event.timezone, - }); - return ["vcalendar", [], [vevent, vtimezone.component.jCal]]; + return vevent; } + function formatDateToICal(date: Date, allday: Boolean) { // Format date like: 2025-02-14T11:00:00 (local time)