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" },