From 6874b9ee7012ae7b81c7a28daa95932bb9403e87 Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:51:13 +0100 Subject: [PATCH] [#565] added move for delegated events, deletes old and put new (#578) Co-authored-by: Camille Moussu --- .../components/EventModifications.test.tsx | 50 +- .../features/Events/EventDisplay.test.tsx | 427 +----------------- .../EventFullDisplay.delegated.test.tsx | 130 ++++++ .../features/Events/EventFullDisplay.test.tsx | 422 +++++++++++++++++ src/features/Events/EventUpdateModal.tsx | 88 +--- .../moveEventBetweenCalendars.ts | 210 +++++++++ src/utils/assertThunkSuccess.tsx | 7 +- 7 files changed, 835 insertions(+), 499 deletions(-) create mode 100644 __test__/features/Events/EventFullDisplay.delegated.test.tsx create mode 100644 __test__/features/Events/EventFullDisplay.test.tsx create mode 100644 src/features/Events/updateEventHelpers/moveEventBetweenCalendars.ts diff --git a/__test__/components/EventModifications.test.tsx b/__test__/components/EventModifications.test.tsx index cc7e053..0cb4b96 100644 --- a/__test__/components/EventModifications.test.tsx +++ b/__test__/components/EventModifications.test.tsx @@ -28,8 +28,6 @@ describe("CalendarApp integration", () => { beforeEach(() => { jest.clearAllMocks(); - const dispatch = jest.fn() as AppDispatch; - jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch); }); const renderCalendar = () => { @@ -89,7 +87,8 @@ describe("CalendarApp integration", () => { }; it("renders the event on the calendar and calendarRef works", async () => { - const dispatch = appHooks.useAppDispatch(); + const dispatch = jest.fn() as AppDispatch; + jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch); renderCalendar(); const calendarRef: React.RefObject = (window as any) @@ -334,7 +333,13 @@ describe("CalendarApp integration", () => { }; it("keeps all attendees event participation on title update", async () => { - const updateSpy = jest.spyOn(eventThunks, "putEventAsync"); + const updateSpy = jest + .spyOn(eventThunks, "putEventAsync") + .mockImplementation((payload) => { + const promise = Promise.resolve(payload); + (promise as any).unwrap = () => promise; + return () => promise as any; + }); const onClose = jest.fn(); renderWithProviders( { }); it("changes normal attendee to need action on time update and no organizer changes", async () => { - const updateSpy = jest.spyOn(eventThunks, "putEventAsync"); + const updateSpy = jest + .spyOn(eventThunks, "putEventAsync") + .mockImplementation((payload) => { + const promise = Promise.resolve(payload); + (promise as any).unwrap = () => promise; + return () => promise as any; + }); const onClose = jest.fn(); renderWithProviders( { // Verify SEQUENCE is incremented expect(updatedEvent?.sequence).toBe(3); // 2 + 1 }); + it("update event attendees on drag", async () => { + // Mock dispatch locally — this test calls createEventHandlers directly + // and does not go through the Redux store or useAppDispatch. const mockDispatch = jest.fn(); - const updateSpy = jest.spyOn(eventThunks, "putEventAsync"); + jest + .spyOn(appHooks, "useAppDispatch") + .mockReturnValue(mockDispatch as unknown as AppDispatch); + + const updateSpy = jest + .spyOn(eventThunks, "putEventAsync") + .mockImplementation((payload) => { + const promise = Promise.resolve(payload); + (promise as any).unwrap = () => promise; + return () => promise as any; + }); const eventHandlers = createEventHandlers({ setSelectedRange: jest.fn(), @@ -513,8 +537,20 @@ describe("CalendarApp integration", () => { }); it("update event attendees on resize", async () => { + // Mock dispatch locally — this test calls createEventHandlers directly + // and does not go through the Redux store or useAppDispatch. const mockDispatch = jest.fn(); - const updateSpy = jest.spyOn(eventThunks, "putEventAsync"); + jest + .spyOn(appHooks, "useAppDispatch") + .mockReturnValue(mockDispatch as unknown as AppDispatch); + + const updateSpy = jest + .spyOn(eventThunks, "putEventAsync") + .mockImplementation((payload) => { + const promise = Promise.resolve(payload); + (promise as any).unwrap = () => promise; + return () => promise as any; + }); const eventHandlers = createEventHandlers({ setSelectedRange: jest.fn(), diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index 0cacd0e..16ea72d 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -1,7 +1,6 @@ import * as appHooks from "@/app/hooks"; import { AppDispatch } from "@/app/store"; import { InfoRow } from "@/components/Event/InfoRow"; -import { LONG_DATE_FORMAT } from "@/components/Event/utils/dateTimeFormatters"; import { stringAvatar, stringToColor, @@ -9,15 +8,7 @@ import { import { DelegationAccess } from "@/features/Calendars/CalendarTypes"; import * as eventThunks from "@/features/Calendars/services"; import EventPreviewModal from "@/features/Events/EventDisplayPreview"; -import EventUpdateModal from "@/features/Events/EventUpdateModal"; -import { - act, - cleanup, - fireEvent, - screen, - waitFor, -} from "@testing-library/react"; -import dayjs from "dayjs"; +import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react"; import { renderWithProviders } from "../../utils/Renderwithproviders"; describe("Event Preview Display", () => { @@ -1752,422 +1743,6 @@ describe("Event Preview Display", () => { }); }); -describe("Event Full Display", () => { - const mockOnClose = jest.fn(); - const day = new Date("2025-01-15T10:00:00.000Z"); // Fixed date in UTC: Jan 15, 2025 10:00 AM UTC - - beforeEach(() => { - jest.clearAllMocks(); - }); - - const preloadedState = { - user: { - userData: { - sub: "test", - email: "test@test.com", - sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", - openpaasId: "667037022b752d0026472254", - }, - organiserData: { - cn: "test", - cal_address: "mailto:test@test.com", - }, - }, - calendars: { - list: { - "667037022b752d0026472254/cal1": { - id: "667037022b752d0026472254/cal1", - name: "First Calendar", - color: "#FF0000", - events: { - event1: { - uid: "event1", - title: "Test Event", - calId: "667037022b752d0026472254/cal1", - start: day.toISOString(), - end: day.toISOString(), - organizer: { cn: "test", cal_address: "test@test.com" }, - 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", - }, - ], - }, - }, - }, - "otherCal/cal": { - id: "otherCal/cal", - name: "Calendar 1", - color: "#FF0000", - events: { - event1: { - uid: "event1", - calId: "otherCal/cal", - title: "Test Event Other cal", - start: day.toISOString(), - end: day.toISOString(), - organizer: { cn: "john", cal_address: "john@test.com" }, - }, - }, - }, - }, - pending: false, - }, - }; - - it("renders correctly event data with fixed timezone", () => { - // Use fixed timezone UTC for consistent test results across all environments - const fixedDate = new Date("2025-01-15T10:00:00.000Z"); // 10AM UTC - const endDate = new Date(fixedDate.getTime() + 3600000); // 11AM UTC - - // With UTC timezone set, formatLocalDateTime produces predictable values - const expectedStart = "2025-01-15T10:00"; // 10:00 AM UTC - const expectedEnd = "2025-01-15T11:00"; // 11:00 AM UTC - - const stateWithFixedDate = { - ...preloadedState, - calendars: { - list: { - "667037022b752d0026472254/cal1": { - ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], - events: { - event1: { - ...preloadedState.calendars.list[ - "667037022b752d0026472254/cal1" - ].events.event1, - start: fixedDate.toISOString(), - end: endDate.toISOString(), - timezone: "UTC", - }, - }, - }, - }, - }, - }; - - renderWithProviders( - , - stateWithFixedDate - ); - - expect(screen.getByDisplayValue("Test Event")).toBeInTheDocument(); - - // Expand to show date/time inputs (normal mode shows DateTimeSummary) - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); - - const startDateInput = screen.getByTestId("start-date-input"); - const startTimeInput = screen.getByTestId("start-time-input"); - const endTimeInput = screen.getByTestId("end-time-input"); - - expect(startDateInput).toBeInTheDocument(); - expect(startTimeInput).toBeInTheDocument(); - expect(endTimeInput).toBeInTheDocument(); - - expect(startDateInput).toHaveValue( - dayjs(expectedStart).format(LONG_DATE_FORMAT) - ); - expect(startTimeInput).toHaveValue(dayjs(expectedStart).format("HH:mm")); - - expect(endTimeInput).toHaveValue(dayjs(expectedEnd).format("HH:mm")); - - expect(screen.getByText("First Calendar")).toBeInTheDocument(); - }); - it("calls onClose when Cancel clicked", () => { - renderWithProviders( - , - preloadedState - ); - fireEvent.click(screen.getAllByTestId("CloseIcon")[0]); - - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); - }); - it("toggle Show More reveals extra fields", async () => { - renderWithProviders( - , - preloadedState - ); - act(() => { - fireEvent.click( - screen.getByRole("button", { name: "common.moreOptions" }) - ); - }); - - await waitFor(() => { - expect(screen.getByText("event.form.notification")).toBeInTheDocument(); - }); - - // Debug: Print DOM to see what's rendered - console.log("DOM after Show More clicked:", document.body.innerHTML); - - // EventDisplay modal doesn't have Repeat checkbox, only RepeatEvent component - // which shows repetition settings when repetition data exists - // Since test event has no repetition data, RepeatEvent component won't show Repeat checkbox - fireEvent.click(screen.getByRole("button", { name: /Show Less/i })); - }); - - it("can edit title when user is organizer", () => { - renderWithProviders( - , - preloadedState - ); - const titleField = screen.getByLabelText("event.form.title"); - fireEvent.change(titleField, { target: { value: "New Title" } }); - expect(screen.getByDisplayValue("New Title")).toBeInTheDocument(); - }); - it("toggle all-day updates end date correctly", () => { - renderWithProviders( - , - preloadedState - ); - - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); - const allDayCheckbox = screen.getByLabelText("event.form.allDay"); - fireEvent.click(allDayCheckbox); - expect(allDayCheckbox).toBeChecked(); - - const expectedDate = dayjs(day).format(LONG_DATE_FORMAT); - - const startDateInput = screen.getByTestId("start-date-input"); - const endDateInput = screen.getByTestId("end-date-input"); - - expect(startDateInput).toHaveValue(expectedDate); - expect(endDateInput).toHaveValue(expectedDate); - }); - - it("saves event and moves it when calendar is changed", async () => { - const spyPut = jest - .spyOn(eventThunks, "putEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); - const spyMove = jest - .spyOn(eventThunks, "moveEventAsync") - .mockImplementation((payload) => { - const promise = Promise.resolve(payload); - (promise as any).unwrap = () => promise; - return () => promise as any; - }); - - const testDate = new Date("2025-01-15T10:00:00.000Z"); - const testEndDate = new Date("2025-01-15T11:00:00.000Z"); - const preloadedTwoCals = { - ...preloadedState, - calendars: { - list: { - "667037022b752d0026472254/cal1": { - id: "667037022b752d0026472254/cal1", - name: "Calendar One", - color: "#FF0000", - events: { - event1: { - uid: "event1", - title: "Test Event", - calId: "667037022b752d0026472254/cal1", - start: testDate.toISOString(), - end: testEndDate.toISOString(), - organizer: { cn: "test", cal_address: "test@test.com" }, - attendee: [{ cn: "test", cal_address: "test@test.com" }], - }, - }, - }, - "667037022b752d0026472254/cal2": { - id: "667037022b752d0026472254/cal2", - name: "Calendar Two", - color: "#00FF00", - events: {}, - }, - }, - pending: false, - }, - }; - - await act(async () => - renderWithProviders( - , - preloadedTwoCals - ) - ); - - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); - const calendarSelect = screen.getByLabelText("event.form.calendar"); - await act(async () => fireEvent.mouseDown(calendarSelect)); - - const option = await screen.findByText("Calendar Two"); - fireEvent.click(option); - - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "actions.save" })); - }); - - await waitFor( - () => { - expect(spyPut).toHaveBeenCalled(); - }, - { timeout: 3000 } - ); - - await waitFor( - () => { - expect(spyMove).toHaveBeenCalled(); - }, - { timeout: 3000 } - ); - }); - - it("edit modal displays event time in original event timezone", () => { - // GIVEN user timezone is UTC+2 - // WHEN the user edits an event at 2PM UTC+7 (Asia/Bangkok) - // THEN the update modal displays the time as 2PM in Asia/Bangkok timezone - - const eventDateUTC7 = new Date("2025-01-15T07:00:00.000Z"); // 7AM UTC = 2PM UTC+7 - - const stateWithTimezone = { - ...preloadedState, - calendars: { - list: { - "667037022b752d0026472254/cal1": { - id: "667037022b752d0026472254/cal1", - name: "Test Calendar", - color: "#FF0000", - events: { - event1: { - uid: "event1", - title: "Timezone Edit Test", - calId: "667037022b752d0026472254/cal1", - start: eventDateUTC7.toISOString(), - end: new Date(eventDateUTC7.getTime() + 3600000).toISOString(), - timezone: "Asia/Bangkok", - allday: false, - organizer: { cn: "test", cal_address: "test@test.com" }, - attendee: [{ cn: "test", cal_address: "test@test.com" }], - }, - }, - }, - }, - pending: false, - }, - }; - - renderWithProviders( - , - stateWithTimezone - ); - - // Expand to show timezone selector (normal mode hides it) - fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); - // The timezone select should have Asia/Bangkok selected - const timeZone = screen.getByDisplayValue(/Asia\/Bangkok/i); - expect(timeZone).toBeInTheDocument(); - }); - - it("InfoRow renders error style when error prop is true", () => { - renderWithProviders(i} text="Bad" error />); - expect(screen.getByText("Bad")).toBeInTheDocument(); - }); - - it("can remove an attendee with the close button", () => { - renderWithProviders( - , - preloadedState - ); - - const removeBtn = screen.getAllByTestId("CloseIcon").pop()!; - fireEvent.click(removeBtn); - - expect(screen.queryByText(/John/)).not.toBeInTheDocument(); - }); - - it("renders video conference info when x_openpass_videoconference exists", () => { - const videoState = { - ...preloadedState, - calendars: { - list: { - "667037022b752d0026472254/cal1": { - ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], - events: { - event1: { - ...preloadedState.calendars.list[ - "667037022b752d0026472254/cal1" - ].events.event1, - x_openpass_videoconference: "https://meet.test/video", - }, - }, - }, - }, - }, - }; - - renderWithProviders( - , - videoState - ); - - expect( - screen.getByText("event.form.joinVisioConference") - ).toBeInTheDocument(); - }); -}); - describe("Helper functions", () => { it("stringToColor generates consistent color", () => { expect(stringToColor("Alice")).toMatch(/^#[0-9a-f]{6}$/); diff --git a/__test__/features/Events/EventFullDisplay.delegated.test.tsx b/__test__/features/Events/EventFullDisplay.delegated.test.tsx new file mode 100644 index 0000000..81d1a05 --- /dev/null +++ b/__test__/features/Events/EventFullDisplay.delegated.test.tsx @@ -0,0 +1,130 @@ +import * as eventThunks from "@/features/Calendars/services"; +import EventUpdateModal from "@/features/Events/EventUpdateModal"; +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import { renderWithProviders } from "../../utils/Renderwithproviders"; + +describe("Event Full Display — delegated calendar move", () => { + const mockOnClose = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const testDate = new Date("2025-01-15T10:00:00.000Z"); + const testEndDate = new Date("2025-01-15T11:00:00.000Z"); + + const baseUserState = { + user: { + userData: { + sub: "test", + email: "test@test.com", + sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", + openpaasId: "user1", + }, + organiserData: { + cn: "test", + cal_address: "test@test.com", + }, + }, + }; + + const preloadedDelegatedCals = { + ...baseUserState, + calendars: { + pending: false, + list: { + "user1/cal1": { + id: "user1/cal1", + name: "Calendar One", + color: "#FF0000", + events: { + event1: { + uid: "event1", + title: "Test Event", + calId: "user1/cal1", + start: testDate.toISOString(), + end: testEndDate.toISOString(), + organizer: { cn: "test", cal_address: "test@test.com" }, + attendee: [{ cn: "test", cal_address: "test@test.com" }], + URL: "/calendar/user1/cal1/event1.ics", + }, + }, + link: "/calendar/user1/cal1.json", + }, + "user2/cal2": { + id: "user2/cal2", + name: "Delegated Calendar", + color: "#00FF00", + delegated: true, + owner: { + emails: ["delegate@test.com"], + name: "Delegate User", + }, + events: {}, + link: "/calendar/user2/cal2.json", + }, + }, + }, + }; + + it("calls deleteEventAsync and putEventAsync — and NOT moveEventAsync — when moving to a delegated calendar", async () => { + const spyPut = jest + .spyOn(eventThunks, "putEventAsync") + .mockImplementation((payload) => { + const promise = Promise.resolve(payload); + (promise as any).unwrap = () => promise; + return () => promise as any; + }); + const spyDelete = jest + .spyOn(eventThunks, "deleteEventAsync") + .mockImplementation((payload) => { + const promise = Promise.resolve(payload); + (promise as any).unwrap = () => promise; + return () => promise as any; + }); + const spyMove = jest + .spyOn(eventThunks, "moveEventAsync") + .mockImplementation((payload) => { + const promise = Promise.resolve(payload); + (promise as any).unwrap = () => promise; + return () => promise as any; + }); + + await act(async () => + renderWithProviders( + , + preloadedDelegatedCals + ) + ); + + fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); + + const calendarSelect = screen.getByLabelText("event.form.calendar"); + await act(async () => fireEvent.mouseDown(calendarSelect)); + const option = await screen.findByText("Delegated Calendar"); + fireEvent.click(option); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "actions.save" })); + }); + await waitFor( + () => { + expect(spyPut).toHaveBeenCalled(); + }, + { timeout: 3000 } + ); + await waitFor( + () => { + expect(spyDelete).toHaveBeenCalled(); + }, + { timeout: 3000 } + ); + + expect(spyMove).not.toHaveBeenCalled(); + }); +}); diff --git a/__test__/features/Events/EventFullDisplay.test.tsx b/__test__/features/Events/EventFullDisplay.test.tsx new file mode 100644 index 0000000..a27910b --- /dev/null +++ b/__test__/features/Events/EventFullDisplay.test.tsx @@ -0,0 +1,422 @@ +import { InfoRow } from "@/components/Event/InfoRow"; +import { LONG_DATE_FORMAT } from "@/components/Event/utils/dateTimeFormatters"; +import * as eventThunks from "@/features/Calendars/services"; +import EventUpdateModal from "@/features/Events/EventUpdateModal"; +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import dayjs from "dayjs"; +import { renderWithProviders } from "../../utils/Renderwithproviders"; + +describe("Event Full Display", () => { + const mockOnClose = jest.fn(); + const day = new Date("2025-01-15T10:00:00.000Z"); // Fixed date in UTC: Jan 15, 2025 10:00 AM UTC + + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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: "First Calendar", + color: "#FF0000", + events: { + event1: { + uid: "event1", + title: "Test Event", + calId: "667037022b752d0026472254/cal1", + start: day.toISOString(), + end: day.toISOString(), + organizer: { cn: "test", cal_address: "test@test.com" }, + 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", + }, + ], + }, + }, + }, + "otherCal/cal": { + id: "otherCal/cal", + name: "Calendar 1", + color: "#FF0000", + events: { + event1: { + uid: "event1", + calId: "otherCal/cal", + title: "Test Event Other cal", + start: day.toISOString(), + end: day.toISOString(), + organizer: { cn: "john", cal_address: "john@test.com" }, + }, + }, + }, + }, + }, + }; + + it("renders correctly event data with fixed timezone", () => { + // Use fixed timezone UTC for consistent test results across all environments + const fixedDate = new Date("2025-01-15T10:00:00.000Z"); // 10AM UTC + const endDate = new Date(fixedDate.getTime() + 3600000); // 11AM UTC + + // With UTC timezone set, formatLocalDateTime produces predictable values + const expectedStart = "2025-01-15T10:00"; // 10:00 AM UTC + const expectedEnd = "2025-01-15T11:00"; // 11:00 AM UTC + + const stateWithFixedDate = { + ...preloadedState, + calendars: { + pending: false, + list: { + "667037022b752d0026472254/cal1": { + ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + events: { + event1: { + ...preloadedState.calendars.list[ + "667037022b752d0026472254/cal1" + ].events.event1, + start: fixedDate.toISOString(), + end: endDate.toISOString(), + timezone: "UTC", + }, + }, + }, + }, + }, + }; + + renderWithProviders( + , + stateWithFixedDate + ); + + expect(screen.getByDisplayValue("Test Event")).toBeInTheDocument(); + + // Expand to show date/time inputs (normal mode shows DateTimeSummary) + fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); + + const startDateInput = screen.getByTestId("start-date-input"); + const startTimeInput = screen.getByTestId("start-time-input"); + const endTimeInput = screen.getByTestId("end-time-input"); + + expect(startDateInput).toBeInTheDocument(); + expect(startTimeInput).toBeInTheDocument(); + expect(endTimeInput).toBeInTheDocument(); + + expect(startDateInput).toHaveValue( + dayjs(expectedStart).format(LONG_DATE_FORMAT) + ); + expect(startTimeInput).toHaveValue(dayjs(expectedStart).format("HH:mm")); + + expect(endTimeInput).toHaveValue(dayjs(expectedEnd).format("HH:mm")); + + expect(screen.getByText("First Calendar")).toBeInTheDocument(); + }); + it("calls onClose when Cancel clicked", () => { + renderWithProviders( + , + preloadedState + ); + fireEvent.click(screen.getAllByTestId("CloseIcon")[0]); + + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + it("toggle Show More reveals extra fields", async () => { + renderWithProviders( + , + preloadedState + ); + act(() => { + fireEvent.click( + screen.getByRole("button", { name: "common.moreOptions" }) + ); + }); + + await waitFor(() => { + expect(screen.getByText("event.form.notification")).toBeInTheDocument(); + }); + + // EventDisplay modal doesn't have Repeat checkbox, only RepeatEvent component + // which shows repetition settings when repetition data exists + // Since test event has no repetition data, RepeatEvent component won't show Repeat checkbox + fireEvent.click(screen.getByRole("button", { name: /Show Less/i })); + }); + + it("can edit title when user is organizer", () => { + renderWithProviders( + , + preloadedState + ); + const titleField = screen.getByLabelText("event.form.title"); + fireEvent.change(titleField, { target: { value: "New Title" } }); + expect(screen.getByDisplayValue("New Title")).toBeInTheDocument(); + }); + it("toggle all-day updates end date correctly", () => { + renderWithProviders( + , + preloadedState + ); + + fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); + const allDayCheckbox = screen.getByLabelText("event.form.allDay"); + fireEvent.click(allDayCheckbox); + expect(allDayCheckbox).toBeChecked(); + + const expectedDate = dayjs(day).format(LONG_DATE_FORMAT); + + const startDateInput = screen.getByTestId("start-date-input"); + const endDateInput = screen.getByTestId("end-date-input"); + + expect(startDateInput).toHaveValue(expectedDate); + expect(endDateInput).toHaveValue(expectedDate); + }); + + it("saves event and moves it when calendar is changed", async () => { + const spyPut = jest + .spyOn(eventThunks, "putEventAsync") + .mockImplementation((payload) => { + const promise = Promise.resolve(payload); + (promise as any).unwrap = () => promise; + return () => promise as any; + }); + const spyMove = jest + .spyOn(eventThunks, "moveEventAsync") + .mockImplementation((payload) => { + const promise = Promise.resolve(payload); + (promise as any).unwrap = () => promise; + return () => promise as any; + }); + + const testDate = new Date("2025-01-15T10:00:00.000Z"); + const testEndDate = new Date("2025-01-15T11:00:00.000Z"); + const preloadedTwoCals = { + ...preloadedState, + calendars: { + pending: false, + list: { + "667037022b752d0026472254/cal1": { + id: "667037022b752d0026472254/cal1", + name: "Calendar One", + color: "#FF0000", + events: { + event1: { + uid: "event1", + title: "Test Event", + calId: "667037022b752d0026472254/cal1", + start: testDate.toISOString(), + end: testEndDate.toISOString(), + organizer: { cn: "test", cal_address: "test@test.com" }, + attendee: [{ cn: "test", cal_address: "test@test.com" }], + }, + }, + }, + "667037022b752d0026472254/cal2": { + id: "667037022b752d0026472254/cal2", + name: "Calendar Two", + color: "#00FF00", + events: {}, + }, + }, + pending: false, + }, + }; + + await act(async () => + renderWithProviders( + , + preloadedTwoCals + ) + ); + + fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); + const calendarSelect = screen.getByLabelText("event.form.calendar"); + await act(async () => fireEvent.mouseDown(calendarSelect)); + + const option = await screen.findByText("Calendar Two"); + fireEvent.click(option); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "actions.save" })); + }); + + await waitFor( + () => { + expect(spyPut).toHaveBeenCalled(); + }, + { timeout: 3000 } + ); + + await waitFor( + () => { + expect(spyMove).toHaveBeenCalled(); + }, + { timeout: 3000 } + ); + }); + + it("edit modal displays event time in original event timezone", () => { + // GIVEN user timezone is UTC+2 + // WHEN the user edits an event at 2PM UTC+7 (Asia/Bangkok) + // THEN the update modal displays the time as 2PM in Asia/Bangkok timezone + const eventDateUTC7 = new Date("2025-01-15T07:00:00.000Z"); // 7AM UTC = 2PM UTC+7 + + const stateWithTimezone = { + ...preloadedState, + calendars: { + pending: false, + list: { + "667037022b752d0026472254/cal1": { + id: "667037022b752d0026472254/cal1", + name: "Test Calendar", + color: "#FF0000", + events: { + event1: { + uid: "event1", + title: "Timezone Edit Test", + calId: "667037022b752d0026472254/cal1", + start: eventDateUTC7.toISOString(), + end: new Date(eventDateUTC7.getTime() + 3600000).toISOString(), + timezone: "Asia/Bangkok", + allday: false, + organizer: { cn: "test", cal_address: "test@test.com" }, + attendee: [{ cn: "test", cal_address: "test@test.com" }], + }, + }, + }, + }, + pending: false, + }, + }; + + renderWithProviders( + , + stateWithTimezone + ); + + // Expand to show timezone selector (normal mode hides it) + fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" })); + // The timezone select should have Asia/Bangkok selected + const timeZone = screen.getByDisplayValue(/Asia\/Bangkok/i); + expect(timeZone).toBeInTheDocument(); + }); + + it("InfoRow renders error style when error prop is true", () => { + renderWithProviders(i} text="Bad" error />); + expect(screen.getByText("Bad")).toBeInTheDocument(); + }); + + it("can remove an attendee with the close button", () => { + renderWithProviders( + , + preloadedState + ); + + const removeBtn = screen.getAllByTestId("CloseIcon").pop()!; + fireEvent.click(removeBtn); + + expect(screen.queryByText(/John/)).not.toBeInTheDocument(); + }); + + it("renders video conference info when x_openpass_videoconference exists", () => { + const videoState = { + ...preloadedState, + calendars: { + pending: false, + list: { + "667037022b752d0026472254/cal1": { + ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + events: { + event1: { + ...preloadedState.calendars.list[ + "667037022b752d0026472254/cal1" + ].events.event1, + x_openpass_videoconference: "https://meet.test/video", + }, + }, + }, + }, + }, + }; + + renderWithProviders( + , + videoState + ); + + expect( + screen.getByText("event.form.joinVisioConference") + ).toBeInTheDocument(); + }); +}); diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx index 1ea5cca..f292bd4 100644 --- a/src/features/Events/EventUpdateModal.tsx +++ b/src/features/Events/EventUpdateModal.tsx @@ -6,7 +6,6 @@ import { addDays } from "@/components/Event/utils/dateRules"; import { formatDateTimeInTimezone } from "@/components/Event/utils/dateTimeFormatters"; import { convertFormDateTimeToISO } from "@/components/Event/utils/dateTimeHelpers"; import { - moveEventAsync, putEventAsync, updateEventInstanceAsync, updateSeriesAsync, @@ -45,6 +44,7 @@ import { userAttendee } from "../User/models/attendee"; import { deleteEvent, getEvent, putEvent } from "./EventApi"; import { CalendarEvent, RepetitionObject } from "./EventsTypes"; import { detectRecurringEventChanges } from "./eventUtils"; +import { moveEventBetweenCalendars } from "./updateEventHelpers/moveEventBetweenCalendars"; function EventUpdateModal({ eventId, @@ -79,14 +79,12 @@ function EventUpdateModal({ // if the event's calendar is delegated then it shall be the only calendar accessible from the event update modal const userPersonalCalendars: Calendar[] = useMemo(() => { const allCalendars = Object.values(calList) as Calendar[]; - if (calList[calId]?.delegated) { - return [calList[calId]]; - } return allCalendars.filter( (calendar: Calendar) => - calendar.id?.split("/")[0] === user.userData?.openpaasId + calendar.id?.split("/")[0] === user.userData?.openpaasId || + calendar.delegated ); - }, [calList, calId, user.userData?.openpaasId]); + }, [calList, user.userData?.openpaasId]); const timezoneList = useMemo(() => { const zones = Object.keys(TIMEZONES.zones).sort(); @@ -947,7 +945,7 @@ function EventUpdateModal({ ); // Handle result of updateSeriesAsync - assertThunkSuccess(result); + await assertThunkSuccess(result); // Clear cache to ensure navigation shows updated data dispatch(clearFetchCache(calId)); @@ -969,7 +967,7 @@ function EventUpdateModal({ ); // Handle result of putEventAsync - check if rejected first - assertThunkSuccess(result); + await assertThunkSuccess(result); // Remove old single event AFTER new recurring instances are added to store // This prevents empty grid during the transition @@ -980,68 +978,30 @@ function EventUpdateModal({ // Clear temp data on successful save clearEventFormTempData("update"); - } else { - // Normal non-recurring event update - // If calendar is changing, we'll handle it separately with moveEventAsync - // So only call putEventAsync if calendar is NOT changing - if (newCalId === calId) { - const result = await dispatch( - putEventAsync({ cal: targetCalendar, newEvent }) - ); + } else if (newCalId === calId) { + // Normal non-recurring event update (same calendar) + const result = await dispatch( + putEventAsync({ cal: targetCalendar, newEvent }) + ); - // Handle result of putEventAsync - check if rejected first - const typedResult = result as AsyncThunkResult; - if (typedResult.type && typedResult.type.endsWith("/rejected")) { - throw new Error( - typedResult.error?.message || - typedResult.payload?.message || - "API call failed" - ); - } - if (typedResult && typeof typedResult.unwrap === "function") { - await typedResult.unwrap(); - } + await assertThunkSuccess(result); - // Clear temp data on successful save - clearEventFormTempData("update"); - } + // Clear temp data on successful save + clearEventFormTempData("update"); } + // Note: when newCalId !== calId, the move is handled below in the + // "Handle calendar change" block, so we intentionally skip putEventAsync here. } - // Handle calendar change + // Handle calendar change (move to a different calendar) if (newCalId !== calId) { - // Get the old calendar for updating - const oldCalendar = calList[calId]; - if (!oldCalendar) { - console.error("Old calendar not found"); - return; - } - - // First update the event in the old calendar, then move it - const putResult = await dispatch( - putEventAsync({ cal: oldCalendar, newEvent: { ...newEvent, calId } }) - ); - - // Handle result of putEventAsync - const typedPutResult = putResult as AsyncThunkResult; - if (typedPutResult && typeof typedPutResult.unwrap === "function") { - await typedPutResult.unwrap(); - } - - // Then move it to the new calendar - const moveResult = await dispatch( - moveEventAsync({ - cal: targetCalendar, - newEvent, - newURL: `/calendars/${newCalId}/${extractEventBaseUuid(event.uid)}.ics`, - }) - ); - - // Handle result of moveEventAsync - const typedMoveResult = moveResult as AsyncThunkResult; - if (typedMoveResult && typeof typedMoveResult.unwrap === "function") { - await typedMoveResult.unwrap(); - } + await moveEventBetweenCalendars({ + dispatch, + calList, + newEvent, + oldCalId: calId, + newCalId, + }); // Clear temp data on successful move clearEventFormTempData("update"); diff --git a/src/features/Events/updateEventHelpers/moveEventBetweenCalendars.ts b/src/features/Events/updateEventHelpers/moveEventBetweenCalendars.ts new file mode 100644 index 0000000..ea178e6 --- /dev/null +++ b/src/features/Events/updateEventHelpers/moveEventBetweenCalendars.ts @@ -0,0 +1,210 @@ +import { AppDispatch } from "@/app/store"; +import { Calendar } from "@/features/Calendars/CalendarTypes"; +import { + deleteEventAsync, + moveEventAsync, + putEventAsync, +} from "@/features/Calendars/services"; +import { AsyncThunkResult } from "@/features/Calendars/types/AsyncThunkResult"; +import { userAttendee } from "@/features/User/models/attendee"; +import { userOrganiser } from "@/features/User/userDataTypes"; +import { assertThunkSuccess } from "@/utils/assertThunkSuccess"; +import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; +import { makeDisplayName } from "@/utils/makeDisplayName"; +import { CalendarEvent } from "../EventsTypes"; +import { buildDelegatedEventURL } from "../eventUtils"; + +export interface MoveEventBetweenCalendarsParams { + dispatch: AppDispatch; + calList: Record; + newEvent: CalendarEvent; + oldCalId: string; + newCalId: string; +} + +function resolveOrganizerForCalendar( + calendar: Calendar, + originalOrganizer: CalendarEvent["organizer"] +): CalendarEvent["organizer"] { + const ownerEmail = calendar.owner?.emails?.[0]; + if (!ownerEmail) { + return originalOrganizer; + } + + return { + ...originalOrganizer, + cal_address: ownerEmail, + cn: makeDisplayName(calendar) ?? originalOrganizer?.cn ?? "", + }; +} + +function rewriteAttendeesForOrganizerChange( + attendees: userAttendee[], + oldOrganizer?: userOrganiser, + newOrganizer?: userOrganiser +): userAttendee[] { + if (!newOrganizer) { + return attendees; + } + const normalise = (addr: string | undefined) => (addr ?? "").toLowerCase(); + const oldAddr = normalise(oldOrganizer?.cal_address); + const newAddr = normalise(newOrganizer.cal_address); + + // Remove the old organizer from the attendee list if there is one + const filtered = attendees.filter( + (a) => normalise(a.cal_address) !== oldAddr + ); + + // Add the new organizer as CHAIR if they are not already listed + const alreadyPresent = filtered.some( + (a) => normalise(a.cal_address) === newAddr + ); + + if (!alreadyPresent && newAddr) { + filtered.push({ + cal_address: newOrganizer.cal_address, + partstat: "ACCEPTED", + role: "CHAIR", + rsvp: "FALSE", + cn: newOrganizer.cn || newOrganizer.cal_address, + cutype: "INDIVIDUAL", + }); + } + + return filtered; +} + +export async function moveEventBetweenCalendars({ + dispatch, + calList, + newEvent, + oldCalId, + newCalId, +}: MoveEventBetweenCalendarsParams): Promise { + const oldCalendar = calList[oldCalId]; + if (!oldCalendar) { + throw new Error(`Old calendar not found: ${oldCalId}`); + } + + const targetCalendar = calList[newCalId]; + if (!targetCalendar) { + throw new Error(`Target calendar not found: ${newCalId}`); + } + + const isDelegatedMove = oldCalendar.delegated || targetCalendar.delegated; + + if (isDelegatedMove) { + await moveDelegatedEvent({ + dispatch, + newEvent, + oldCalendar, + targetCalendar, + }); + } else { + await moveStandardEvent({ + dispatch, + newEvent, + targetCalendar, + oldCalendar, + }); + } +} + +interface StandardMoveParams { + dispatch: AppDispatch; + newEvent: CalendarEvent; + targetCalendar: Calendar; + oldCalendar: Calendar; +} + +async function moveStandardEvent({ + dispatch, + newEvent, + targetCalendar, + oldCalendar, +}: StandardMoveParams): Promise { + const newCalId = targetCalendar.id; + + const putResult = await dispatch( + putEventAsync({ + cal: oldCalendar, + newEvent: { ...newEvent, calId: oldCalendar.id }, + }) + ); + await assertThunkSuccess(putResult); + const newURL = `/calendars/${newCalId}/${extractEventBaseUuid(newEvent.uid)}.ics`; + + const moveResult = await dispatch( + moveEventAsync({ + cal: targetCalendar, + newEvent, + newURL, + }) + ); + await assertThunkSuccess(moveResult); +} + +interface DelegatedMoveParams { + dispatch: AppDispatch; + newEvent: CalendarEvent; + oldCalendar: Calendar; + targetCalendar: Calendar; +} + +async function moveDelegatedEvent({ + dispatch, + newEvent, + oldCalendar, + targetCalendar, +}: DelegatedMoveParams): Promise { + const newCalId = targetCalendar.id; + + const newOrganizer = resolveOrganizerForCalendar( + targetCalendar, + newEvent.organizer + ); + + const newAttendees = rewriteAttendeesForOrganizerChange( + newEvent.attendee ?? [], + newEvent.organizer, + newOrganizer + ); + + const newURL = `/calendars/${newCalId}/${extractEventBaseUuid(newEvent.uid)}.ics`; + + const eventForTargetCalendar: CalendarEvent = { + ...newEvent, + calId: newCalId, + URL: targetCalendar.delegated + ? buildDelegatedEventURL(targetCalendar, newURL) + : newURL, + organizer: newOrganizer, + attendee: newAttendees, + }; + + const putResult = await dispatch( + putEventAsync({ cal: targetCalendar, newEvent: eventForTargetCalendar }) + ); + + const typedPutResult = putResult as AsyncThunkResult; + if (typedPutResult && typeof typedPutResult.unwrap === "function") { + await typedPutResult.unwrap(); + } else { + await assertThunkSuccess(putResult); + } + + const deleteResult = await dispatch( + deleteEventAsync({ + calId: oldCalendar.id, + eventId: newEvent.uid, + eventURL: newEvent.URL, + }) + ); + + const typedDeleteResult = deleteResult as AsyncThunkResult; + if (typedDeleteResult && typeof typedDeleteResult.unwrap === "function") { + await typedDeleteResult.unwrap(); + } else { + await assertThunkSuccess(deleteResult); + } +} diff --git a/src/utils/assertThunkSuccess.tsx b/src/utils/assertThunkSuccess.tsx index 1525738..59440bb 100644 --- a/src/utils/assertThunkSuccess.tsx +++ b/src/utils/assertThunkSuccess.tsx @@ -1,10 +1,13 @@ import { AsyncThunkResult } from "@/features/Calendars/types/AsyncThunkResult"; export async function assertThunkSuccess(result: unknown): Promise { + if (result === undefined || result === null) { + return; + } const typed = result as AsyncThunkResult; - if (typed?.type?.endsWith("/rejected")) { + if (typed.type && typed.type.endsWith("/rejected")) { throw new Error( - typed.error?.message || typed.payload?.message || "API call failed" + typed.error?.message ?? typed.payload?.message ?? "Thunk was rejected" ); } if (typeof typed.unwrap === "function") {