307 event modification reset user participation (#316)
Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
@@ -2,10 +2,22 @@ import { CalendarApi } from "@fullcalendar/core";
|
|||||||
import { jest } from "@jest/globals";
|
import { jest } from "@jest/globals";
|
||||||
import { ThunkDispatch } from "@reduxjs/toolkit";
|
import { ThunkDispatch } from "@reduxjs/toolkit";
|
||||||
import "@testing-library/jest-dom";
|
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 appHooks from "../../src/app/hooks";
|
||||||
|
import * as eventThunks from "../../src/features/Calendars/CalendarSlice";
|
||||||
import CalendarApp from "../../src/components/Calendar/Calendar";
|
import CalendarApp from "../../src/components/Calendar/Calendar";
|
||||||
|
import EventUpdateModal from "../../src/features/Events/EventUpdateModal";
|
||||||
import { renderWithProviders } from "../utils/Renderwithproviders";
|
import { renderWithProviders } from "../utils/Renderwithproviders";
|
||||||
|
import {
|
||||||
|
createEventHandlers,
|
||||||
|
EventHandlersProps,
|
||||||
|
} from "../../src/components/Calendar/handlers/eventHandlers";
|
||||||
|
|
||||||
describe("CalendarApp integration", () => {
|
describe("CalendarApp integration", () => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -260,4 +272,283 @@ describe("CalendarApp integration", () => {
|
|||||||
|
|
||||||
expect(screen.getByText("Public Event")).toBeInTheDocument();
|
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(
|
||||||
|
<EventUpdateModal
|
||||||
|
eventId="event1"
|
||||||
|
calId="667037022b752d0026472254/cal1"
|
||||||
|
open={true}
|
||||||
|
onClose={onClose}
|
||||||
|
typeOfAction="solo"
|
||||||
|
/>,
|
||||||
|
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(
|
||||||
|
<EventUpdateModal
|
||||||
|
eventId="event1"
|
||||||
|
calId="667037022b752d0026472254/cal1"
|
||||||
|
open={true}
|
||||||
|
onClose={onClose}
|
||||||
|
typeOfAction="solo"
|
||||||
|
/>,
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { refreshCalendars } from "../../Event/utils/eventUtils";
|
|||||||
import { updateTempCalendar } from "../utils/calendarUtils";
|
import { updateTempCalendar } from "../utils/calendarUtils";
|
||||||
import { User } from "../../Attendees/PeopleSearch";
|
import { User } from "../../Attendees/PeopleSearch";
|
||||||
import { formatLocalDateTime } from "../../Event/utils/dateTimeFormatters";
|
import { formatLocalDateTime } from "../../Event/utils/dateTimeFormatters";
|
||||||
|
import { userAttendee } from "../../../features/User/userDataTypes";
|
||||||
|
|
||||||
export interface EventHandlersProps {
|
export interface EventHandlersProps {
|
||||||
setSelectedRange: (range: DateSelectArg | null) => void;
|
setSelectedRange: (range: DateSelectArg | null) => void;
|
||||||
@@ -171,11 +172,14 @@ export const createEventHandlers = (props: EventHandlersProps) => {
|
|||||||
const computedNewStart = new Date(originalStart.getTime() + totalDeltaMs);
|
const computedNewStart = new Date(originalStart.getTime() + totalDeltaMs);
|
||||||
const originalEnd = new Date(event.end ?? "");
|
const originalEnd = new Date(event.end ?? "");
|
||||||
const computedNewEnd = new Date(originalEnd.getTime() + totalDeltaMs);
|
const computedNewEnd = new Date(originalEnd.getTime() + totalDeltaMs);
|
||||||
const newEvent = {
|
const newEvent = updateAttendeesAfterTimeChange(
|
||||||
...event,
|
{
|
||||||
start: computedNewStart.toISOString(),
|
...event,
|
||||||
end: computedNewEnd.toISOString(),
|
start: computedNewStart.toISOString(),
|
||||||
} as CalendarEvent;
|
end: computedNewEnd.toISOString(),
|
||||||
|
} as CalendarEvent,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
if (isRecurring) {
|
if (isRecurring) {
|
||||||
setSelectedEvent(event);
|
setSelectedEvent(event);
|
||||||
@@ -245,11 +249,14 @@ export const createEventHandlers = (props: EventHandlersProps) => {
|
|||||||
const computedNewEnd = new Date(
|
const computedNewEnd = new Date(
|
||||||
originalEnd.getTime() + getDeltaInMilliseconds(arg.endDelta)
|
originalEnd.getTime() + getDeltaInMilliseconds(arg.endDelta)
|
||||||
);
|
);
|
||||||
const newEvent = {
|
const newEvent = updateAttendeesAfterTimeChange(
|
||||||
...event,
|
{
|
||||||
start: computedNewStart.toISOString(),
|
...event,
|
||||||
end: computedNewEnd.toISOString(),
|
start: computedNewStart.toISOString(),
|
||||||
} as CalendarEvent;
|
end: computedNewEnd.toISOString(),
|
||||||
|
} as CalendarEvent,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
if (isRecurring) {
|
if (isRecurring) {
|
||||||
setSelectedEvent(event);
|
setSelectedEvent(event);
|
||||||
@@ -308,3 +315,55 @@ export const createEventHandlers = (props: EventHandlersProps) => {
|
|||||||
handleEventResize,
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
} from "./eventUtils";
|
} from "./eventUtils";
|
||||||
import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils";
|
import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils";
|
||||||
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
|
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
|
||||||
|
import { updateAttendeesAfterTimeChange } from "../../components/Calendar/handlers/eventHandlers";
|
||||||
|
|
||||||
const showErrorNotification = (message: string) => {
|
const showErrorNotification = (message: string) => {
|
||||||
console.error(`[ERROR] ${message}`);
|
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 = {
|
const newEvent: CalendarEvent = {
|
||||||
|
...updateAttendeesAfterTimeChange(event, timeChanged, attendees),
|
||||||
calId: newCalId || calId,
|
calId: newCalId || calId,
|
||||||
title,
|
title,
|
||||||
URL: event.URL ?? `/calendars/${newCalId || calId}/${event.uid}.ics`,
|
URL: event.URL ?? `/calendars/${newCalId || calId}/${event.uid}.ics`,
|
||||||
@@ -421,9 +429,6 @@ function EventUpdateModal({
|
|||||||
class: eventClass,
|
class: eventClass,
|
||||||
organizer: organizer,
|
organizer: organizer,
|
||||||
timezone,
|
timezone,
|
||||||
attendee: organizer
|
|
||||||
? [organizer as userAttendee, ...attendees]
|
|
||||||
: attendees,
|
|
||||||
transp: busy,
|
transp: busy,
|
||||||
color: targetCalendar?.color,
|
color: targetCalendar?.color,
|
||||||
alarm: { trigger: alarm, action: "EMAIL" },
|
alarm: { trigger: alarm, action: "EMAIL" },
|
||||||
|
|||||||
Reference in New Issue
Block a user