diff --git a/__test__/components/EventModifications.test.tsx b/__test__/components/EventModifications.test.tsx index 185f698..260603a 100644 --- a/__test__/components/EventModifications.test.tsx +++ b/__test__/components/EventModifications.test.tsx @@ -2,10 +2,22 @@ import { CalendarApi } from "@fullcalendar/core"; import { jest } from "@jest/globals"; import { ThunkDispatch } from "@reduxjs/toolkit"; import "@testing-library/jest-dom"; -import { act, screen, waitFor, within } from "@testing-library/react"; +import { + act, + fireEvent, + screen, + waitFor, + within, +} from "@testing-library/react"; import * as appHooks from "../../src/app/hooks"; +import * as eventThunks from "../../src/features/Calendars/CalendarSlice"; import CalendarApp from "../../src/components/Calendar/Calendar"; +import EventUpdateModal from "../../src/features/Events/EventUpdateModal"; import { renderWithProviders } from "../utils/Renderwithproviders"; +import { + createEventHandlers, + EventHandlersProps, +} from "../../src/components/Calendar/handlers/eventHandlers"; describe("CalendarApp integration", () => { const today = new Date(); @@ -260,4 +272,283 @@ describe("CalendarApp integration", () => { expect(screen.getByText("Public Event")).toBeInTheDocument(); }); + describe("BUGFIX", () => { + const preloadedState = { + user: { + userData: { + sub: "test", + email: "test@test.com", + sid: "mockSid", + openpaasId: "667037022b752d0026472254", + }, + tokens: { + accessToken: "token", + }, + }, + calendars: { + list: { + "667037022b752d0026472254/cal1": { + name: "Calendar 1", + id: "667037022b752d0026472254/cal1", + color: { light: "#FFFFFF", dark: "#000000" }, + ownerEmails: ["alice@example.com"], + events: { + event1: { + id: "event1", + calId: "667037022b752d0026472254/cal1", + uid: "event1", + title: "Original Event", + start: new Date("2025-11-14T10:31:00.000Z").toISOString(), + end: new Date("2025-11-14T11:31:00.000Z").toISOString(), + class: "PUBLIC", + partstat: "ACCEPTED", + organizer: { + cn: "Alice", + cal_address: "alice@example.com", + }, + attendee: [ + { + cn: "Alice", + partstat: "ACCEPTED", + rsvp: "TRUE", + role: "CHAIR", + cutype: "INDIVIDUAL", + cal_address: "alice@example.com", + }, + { + cn: "Bob", + partstat: "ACCEPTED", + rsvp: "TRUE", + role: "CHAIR", + cutype: "INDIVIDUAL", + cal_address: "bob@example.com", + }, + ], + }, + }, + }, + }, + pending: false, + }, + }; + + it("keeps all attendees event participation on title update", async () => { + const updateSpy = jest.spyOn(eventThunks, "putEventAsync"); + const onClose = jest.fn(); + renderWithProviders( + , + preloadedState + ); + + const titleInput = await screen.findByDisplayValue("Original Event"); + + await act(async () => { + fireEvent.change(titleInput, "Updated Event"); + }); + + const saveButton = screen.getByRole("button", { name: /save/i }); + await act(async () => { + saveButton.click(); + }); + + await waitFor(() => expect(updateSpy).toHaveBeenCalled()); + + const dispatchedCalls = updateSpy.mock.calls; + expect(dispatchedCalls.length).toBeGreaterThan(0); + const updatedEvent = dispatchedCalls[0][0].newEvent; + + // Ensure organizer attendee info is preserved + const organizerAttendee = updatedEvent?.attendee?.find( + (a: any) => a.cal_address === "alice@example.com" + ); + + expect(organizerAttendee).toBeTruthy(); + expect(organizerAttendee?.partstat).toBe("ACCEPTED"); + expect(organizerAttendee?.role).toBe("CHAIR"); + + // Ensure normal attendee info is preserved too + const normalAttendee = updatedEvent?.attendee?.find( + (a: any) => a.cal_address === "bob@example.com" + ); + + expect(normalAttendee).toBeTruthy(); + expect(normalAttendee?.partstat).toBe("ACCEPTED"); + expect(normalAttendee?.role).toBe("CHAIR"); //will need to be changed as not the right role + }); + + it("changes normal attendee to need action on time update and no organizer changes", async () => { + const updateSpy = jest.spyOn(eventThunks, "putEventAsync"); + const onClose = jest.fn(); + renderWithProviders( + , + preloadedState + ); + + const startDateInput = await screen.findByTestId("start-time-input"); + + await act(async () => { + fireEvent.change(startDateInput, { + target: { value: "08:00" }, + }); + }); + + const saveButton = screen.getByRole("button", { name: /save/i }); + await act(async () => { + saveButton.click(); + }); + + await waitFor(() => expect(updateSpy).toHaveBeenCalled()); + + const dispatchedCalls = updateSpy.mock.calls; + expect(dispatchedCalls.length).toBeGreaterThan(0); + const updatedEvent = dispatchedCalls[0][0].newEvent; + + // Ensure organizer attendee info is preserved + const organizerAttendee = updatedEvent?.attendee?.find( + (a: any) => a.cal_address === "alice@example.com" + ); + + expect(organizerAttendee).toBeTruthy(); + expect(organizerAttendee?.partstat).toBe("ACCEPTED"); + expect(organizerAttendee?.role).toBe("CHAIR"); + + // Ensure normal attendee partstat is updated + const normalAttendee = updatedEvent?.attendee?.find( + (a: any) => a.cal_address === "bob@example.com" + ); + + expect(normalAttendee).toBeTruthy(); + expect(normalAttendee?.partstat).toBe("NEEDS-ACTION"); + expect(normalAttendee?.role).toBe("CHAIR"); //will need to be changed as not the right role + }); + it("update event attendees on drag", async () => { + const mockDispatch = jest.fn(); + const updateSpy = jest.spyOn(eventThunks, "putEventAsync"); + + 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: preloadedState.calendars.list, + setSelectedEvent: jest.fn(), + setAfterChoiceFunc: jest.fn(), + setOpenEditModePopup: jest.fn(), + } as unknown as EventHandlersProps); + + const mockArg = { + event: { + _def: { + extendedProps: { + uid: "event1", + calId: "667037022b752d0026472254/cal1", + }, + }, + }, + // drag event → move by 1 day + delta: { years: 0, months: 0, days: 1, milliseconds: 0 }, + }; + + renderCalendar(); + eventHandlers.handleEventDrop(mockArg); + + expect(updateSpy).toHaveBeenCalled(); + + // Extract the dispatched update event + const dispatched = updateSpy.mock.calls[0]; + + expect(dispatched).toBeTruthy(); + + const updatePayload = dispatched[0]; + const updatedEvent = updatePayload.newEvent; + + // Organizer should remain unchanged + const organizer = updatedEvent.attendee.find( + (a: any) => a.cal_address === "alice@example.com" + ); + expect(organizer?.partstat).toBe("ACCEPTED"); + + // Normal attendee must become NEEDS-ACTION + const normal = updatedEvent.attendee.find( + (a: any) => a.cal_address === "bob@example.com" + ); + expect(normal?.partstat).toBe("NEEDS-ACTION"); + }); + + it("update event attendees on resize", async () => { + const mockDispatch = jest.fn(); + const updateSpy = jest.spyOn(eventThunks, "putEventAsync"); + + 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: preloadedState.calendars.list, + setSelectedEvent: jest.fn(), + setAfterChoiceFunc: jest.fn(), + setOpenEditModePopup: jest.fn(), + } as unknown as EventHandlersProps); + + const mockArg = { + event: { + _def: { + extendedProps: { + uid: "event1", + calId: "667037022b752d0026472254/cal1", + }, + }, + }, + startDelta: { years: 0, months: 0, days: 0, milliseconds: 0 }, + endDelta: { years: 0, months: 0, days: 0, milliseconds: 3600000 }, // 1 hour + }; + + renderCalendar(); + eventHandlers.handleEventResize(mockArg); + + expect(updateSpy).toHaveBeenCalled(); + + // Extract the dispatched update event + const dispatched = updateSpy.mock.calls[0]; + + expect(dispatched).toBeTruthy(); + + const updatePayload = dispatched[0]; + const updatedEvent = updatePayload.newEvent; + + // Organizer should remain unchanged + const organizer = updatedEvent.attendee.find( + (a: any) => a.cal_address === "alice@example.com" + ); + expect(organizer?.partstat).toBe("ACCEPTED"); + + // Normal attendee must become NEEDS-ACTION + const normal = updatedEvent.attendee.find( + (a: any) => a.cal_address === "bob@example.com" + ); + expect(normal?.partstat).toBe("NEEDS-ACTION"); + }); + }); }); diff --git a/src/components/Calendar/handlers/eventHandlers.ts b/src/components/Calendar/handlers/eventHandlers.ts index 9bd6c9a..fd71445 100644 --- a/src/components/Calendar/handlers/eventHandlers.ts +++ b/src/components/Calendar/handlers/eventHandlers.ts @@ -17,6 +17,7 @@ import { refreshCalendars } from "../../Event/utils/eventUtils"; import { updateTempCalendar } from "../utils/calendarUtils"; import { User } from "../../Attendees/PeopleSearch"; import { formatLocalDateTime } from "../../Event/utils/dateTimeFormatters"; +import { userAttendee } from "../../../features/User/userDataTypes"; export interface EventHandlersProps { setSelectedRange: (range: DateSelectArg | null) => void; @@ -171,11 +172,14 @@ export const createEventHandlers = (props: EventHandlersProps) => { const computedNewStart = new Date(originalStart.getTime() + totalDeltaMs); const originalEnd = new Date(event.end ?? ""); const computedNewEnd = new Date(originalEnd.getTime() + totalDeltaMs); - const newEvent = { - ...event, - start: computedNewStart.toISOString(), - end: computedNewEnd.toISOString(), - } as CalendarEvent; + const newEvent = updateAttendeesAfterTimeChange( + { + ...event, + start: computedNewStart.toISOString(), + end: computedNewEnd.toISOString(), + } as CalendarEvent, + true + ); if (isRecurring) { setSelectedEvent(event); @@ -245,11 +249,14 @@ export const createEventHandlers = (props: EventHandlersProps) => { const computedNewEnd = new Date( originalEnd.getTime() + getDeltaInMilliseconds(arg.endDelta) ); - const newEvent = { - ...event, - start: computedNewStart.toISOString(), - end: computedNewEnd.toISOString(), - } as CalendarEvent; + const newEvent = updateAttendeesAfterTimeChange( + { + ...event, + start: computedNewStart.toISOString(), + end: computedNewEnd.toISOString(), + } as CalendarEvent, + true + ); if (isRecurring) { setSelectedEvent(event); @@ -308,3 +315,55 @@ export const createEventHandlers = (props: EventHandlersProps) => { handleEventResize, }; }; + +export const updateAttendeesAfterTimeChange = ( + event: CalendarEvent, + timeChanged?: boolean, + attendees?: userAttendee[] +): CalendarEvent => { + const { attendee, organizer } = event; + if (!attendee || !organizer) return event; + + const organizerAddr = organizer.cal_address; + + const markNeedsAction = (att: userAttendee): userAttendee => ({ + ...att, + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + }); + + const getExistingOrDefault = (addr: string, fallback: userAttendee) => + attendee.find((a) => a?.cal_address === addr) ?? fallback; + + if (attendees) { + const updatedAttendees = attendees.map((att) => { + const existing = getExistingOrDefault( + att.cal_address, + markNeedsAction(att) + ); + return timeChanged ? markNeedsAction(existing) : existing; + }); + + const organizerEntry = getExistingOrDefault(organizerAddr, { + ...organizer, + role: "CHAIR", + cutype: "INDIVIDUAL", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + }); + + return { + ...event, + attendee: [...updatedAttendees, organizerEntry], + }; + } + const updatedAttendees = attendee.map((att) => { + if (att.cal_address === organizerAddr) return att; + return timeChanged ? markNeedsAction(att) : att; + }); + + return { + ...event, + attendee: updatedAttendees, + }; +}; diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx index 3524841..a12c64c 100644 --- a/src/features/Events/EventUpdateModal.tsx +++ b/src/features/Events/EventUpdateModal.tsx @@ -29,6 +29,7 @@ import { } from "./eventUtils"; import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils"; import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; +import { updateAttendeesAfterTimeChange } from "../../components/Calendar/handlers/eventHandlers"; const showErrorNotification = (message: string) => { console.error(`[ERROR] ${message}`); @@ -407,7 +408,14 @@ function EventUpdateModal({ } } + const eventStartChanged = + new Date(event.start).getTime() !== new Date(startDate).getTime(); + const eventEndChanged = + new Date(event?.end ?? "").getTime() !== new Date(endDate).getTime(); + const timeChanged = eventStartChanged || eventEndChanged; + const newEvent: CalendarEvent = { + ...updateAttendeesAfterTimeChange(event, timeChanged, attendees), calId: newCalId || calId, title, URL: event.URL ?? `/calendars/${newCalId || calId}/${event.uid}.ics`, @@ -421,9 +429,6 @@ function EventUpdateModal({ class: eventClass, organizer: organizer, timezone, - attendee: organizer - ? [organizer as userAttendee, ...attendees] - : attendees, transp: busy, color: targetCalendar?.color, alarm: { trigger: alarm, action: "EMAIL" },