* [#490 & #489] Removed duplicated api call to get event data * [#490] removed arbitrary timezone fallback to UTC/ETC & updated exdate formating * [#490] updated test and fixed eventdiff utils to properly handle dates
This commit is contained in:
@@ -512,5 +512,72 @@ describe("CalendarSlice", () => {
|
||||
);
|
||||
expect(state.list.c1.events.e1.uid).toBe("e1");
|
||||
});
|
||||
|
||||
it("getEventAsync.fulfilled doesnt create new events when there are already event with base UID", () => {
|
||||
const baseUid = "recurring-event-base";
|
||||
const existingEvent = {
|
||||
uid: `${baseUid}/20240115`,
|
||||
title: "Existing Recurring Event",
|
||||
recurrenceId: null,
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
const newEventInstance = {
|
||||
uid: baseUid,
|
||||
title: "Fetched Master Event",
|
||||
recurrenceId: "20240115",
|
||||
} as CalendarEvent;
|
||||
|
||||
const stateWithEvent = {
|
||||
...initialState,
|
||||
list: {
|
||||
c1: {
|
||||
id: "c1",
|
||||
events: { [`${baseUid}/20240115`]: existingEvent },
|
||||
} as unknown as Calendar,
|
||||
},
|
||||
};
|
||||
|
||||
const payload = { calId: "c1", event: newEventInstance };
|
||||
const state = reducer(
|
||||
stateWithEvent,
|
||||
getEventAsync.fulfilled(payload, "req", newEventInstance)
|
||||
);
|
||||
|
||||
// Should still only have the base event, not the instance
|
||||
expect(Object.keys(state.list.c1.events)).toHaveLength(1);
|
||||
expect(state.list.c1.events[`${baseUid}/20240115`]).toBeDefined();
|
||||
expect(state.list.c1.events[baseUid]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getEventAsync.fulfilled create new event when there isn't any event with base UID", () => {
|
||||
const eventUid = "new-event-uid";
|
||||
const newEvent = {
|
||||
uid: eventUid,
|
||||
title: "New Event",
|
||||
recurrenceId: null,
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
const stateWithoutEvent = {
|
||||
...initialState,
|
||||
list: {
|
||||
c1: {
|
||||
id: "c1",
|
||||
events: {},
|
||||
} as unknown as Calendar,
|
||||
},
|
||||
};
|
||||
|
||||
const payload = { calId: "c1", event: newEvent };
|
||||
const state = reducer(
|
||||
stateWithoutEvent,
|
||||
getEventAsync.fulfilled(payload, "req", newEvent)
|
||||
);
|
||||
|
||||
// Should create the new event
|
||||
expect(Object.keys(state.list.c1.events)).toHaveLength(1);
|
||||
expect(state.list.c1.events[eventUid]).toBeDefined();
|
||||
expect(state.list.c1.events[eventUid].uid).toBe(eventUid);
|
||||
expect(state.list.c1.events[eventUid].title).toBe("New Event");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -699,7 +699,6 @@ describe("Edit Recurring Event in Full Display", () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
|
||||
preview.debug();
|
||||
expect(screen.getByText("event.repeat.repeatEvery")).toBeInTheDocument();
|
||||
expect(screen.getByText("event.repeat.end.label")).toBeInTheDocument();
|
||||
|
||||
@@ -1191,7 +1190,6 @@ describe("Event URL handling for recurring events", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
});
|
||||
|
||||
preview.debug();
|
||||
await waitFor(() => {
|
||||
expect(moveEventSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
import * as CalendarApi from "@/features/Calendars/CalendarApi";
|
||||
import * as eventThunks from "@/features/Calendars/services";
|
||||
import * as EventApi from "@/features/Events/EventApi";
|
||||
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||
import EventUpdateModal from "@/features/Events/EventUpdateModal";
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "../../utils/Renderwithproviders";
|
||||
|
||||
jest.mock("@/features/Events/EventApi");
|
||||
jest.mock("@/features/Calendars/CalendarApi");
|
||||
|
||||
describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
const baseUID = "recurring-event-base";
|
||||
const calId = "667037022b752d0026472254/cal1";
|
||||
|
||||
const preloadedState = {
|
||||
user: {
|
||||
userData: {
|
||||
sub: "test",
|
||||
email: "test@test.com",
|
||||
sid: "test-sid",
|
||||
openpaasId: "667037022b752d0026472254",
|
||||
},
|
||||
organiserData: {
|
||||
cn: "test",
|
||||
cal_address: "test@test.com",
|
||||
},
|
||||
},
|
||||
calendars: {
|
||||
list: {
|
||||
[calId]: {
|
||||
id: calId,
|
||||
name: "Test Calendar",
|
||||
color: "#FF0000",
|
||||
events: {},
|
||||
ownerEmails: ["test@test.com"],
|
||||
},
|
||||
},
|
||||
pending: false,
|
||||
},
|
||||
};
|
||||
|
||||
describe("Master Event Display", () => {
|
||||
it("should fetch and display master event when editing 'all events' of a recurring series", async () => {
|
||||
// Given a recurring series with modified instances
|
||||
const masterEvent = {
|
||||
uid: baseUID,
|
||||
title: "Master Event Title",
|
||||
description: "Master Description",
|
||||
calId,
|
||||
start: "2025-01-15T10:00:00.000Z",
|
||||
end: "2025-01-15T11:00:00.000Z",
|
||||
repetition: { freq: "daily", interval: 1 },
|
||||
allday: false,
|
||||
timezone: "America/New_York",
|
||||
organizer: { cn: "test", cal_address: "test@test.com" },
|
||||
attendee: [{ cn: "test", cal_address: "test@test.com" }],
|
||||
URL: `/calendars/${calId}/${baseUID}.ics`,
|
||||
} as CalendarEvent;
|
||||
|
||||
// Instance that was moved to different time
|
||||
const modifiedInstance = {
|
||||
...masterEvent,
|
||||
uid: `${baseUID}/20250116`,
|
||||
title: "Modified Instance Title", // Different title
|
||||
start: "2025-01-16T14:00:00.000Z", // Different time (2PM instead of 10AM)
|
||||
end: "2025-01-16T15:00:00.000Z",
|
||||
};
|
||||
|
||||
const stateWithRecurringEvent = {
|
||||
...preloadedState,
|
||||
calendars: {
|
||||
...preloadedState.calendars,
|
||||
list: {
|
||||
[calId]: {
|
||||
...preloadedState.calendars.list[calId],
|
||||
events: {
|
||||
[baseUID]: masterEvent,
|
||||
[`${baseUID}/20250116`]: modifiedInstance,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Mock getEvent to return master event
|
||||
const mockGetEvent = jest
|
||||
.spyOn(EventApi, "getEvent")
|
||||
.mockResolvedValue(masterEvent);
|
||||
|
||||
renderWithProviders(
|
||||
<EventUpdateModal
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={calId}
|
||||
eventId={`${baseUID}/20250116`} // User clicked on modified instance
|
||||
typeOfAction="all"
|
||||
/>,
|
||||
stateWithRecurringEvent
|
||||
);
|
||||
|
||||
// Wait for master event to be fetched
|
||||
await waitFor(() => {
|
||||
expect(mockGetEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
uid: baseUID, // Should fetch base UID, not instance
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
// Should display master event data, NOT modified instance data
|
||||
await waitFor(() => {
|
||||
const titleInput = screen.getByDisplayValue("Master Event Title");
|
||||
expect(titleInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should NOT show modified instance title
|
||||
expect(
|
||||
screen.queryByDisplayValue("Modified Instance Title")
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Verify timezone dropdown shows master timezone
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue(/New York/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should use master event directly if clicked event is already the master", async () => {
|
||||
const masterEvent = {
|
||||
uid: baseUID, // No recurrence-id
|
||||
title: "Master Event",
|
||||
calId,
|
||||
start: "2025-01-15T10:00:00.000Z",
|
||||
end: "2025-01-15T11:00:00.000Z",
|
||||
repetition: { freq: "weekly", interval: 1 },
|
||||
allday: false,
|
||||
organizer: { cn: "test", cal_address: "test@test.com" },
|
||||
URL: `/calendars/${calId}/${baseUID}.ics`,
|
||||
};
|
||||
|
||||
const stateWithMaster = {
|
||||
...preloadedState,
|
||||
calendars: {
|
||||
...preloadedState.calendars,
|
||||
list: {
|
||||
[calId]: {
|
||||
...preloadedState.calendars.list[calId],
|
||||
events: {
|
||||
[baseUID]: masterEvent,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockGetEvent = jest.spyOn(EventApi, "getEvent");
|
||||
|
||||
renderWithProviders(
|
||||
<EventUpdateModal
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={calId}
|
||||
eventId={baseUID} // Already master event
|
||||
typeOfAction="all"
|
||||
/>,
|
||||
stateWithMaster
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("Master Event")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should NOT fetch from API since we already have master
|
||||
expect(mockGetEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fallback to instance data if master event fetch fails", async () => {
|
||||
const instance = {
|
||||
uid: `${baseUID}/20250115`,
|
||||
title: "Instance Event",
|
||||
calId,
|
||||
start: "2025-01-15T10:00:00.000Z",
|
||||
end: "2025-01-15T11:00:00.000Z",
|
||||
repetition: { freq: "daily", interval: 1 },
|
||||
allday: false,
|
||||
organizer: { cn: "test", cal_address: "test@test.com" },
|
||||
URL: `/calendars/${calId}/${baseUID}.ics`,
|
||||
} as CalendarEvent;
|
||||
|
||||
const stateWithInstance = {
|
||||
...preloadedState,
|
||||
calendars: {
|
||||
...preloadedState.calendars,
|
||||
list: {
|
||||
[calId]: {
|
||||
...preloadedState.calendars.list[calId],
|
||||
events: {
|
||||
[`${baseUID}/20250115`]: instance,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Mock getEvent to fail
|
||||
jest
|
||||
.spyOn(EventApi, "getEvent")
|
||||
.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
|
||||
|
||||
renderWithProviders(
|
||||
<EventUpdateModal
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={calId}
|
||||
eventId={`${baseUID}/20250115`}
|
||||
typeOfAction="all"
|
||||
/>,
|
||||
stateWithInstance
|
||||
);
|
||||
|
||||
// Should still display the instance data as fallback
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("Instance Event")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Failed to fetch master event:",
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Update All Events - Using Base UID", () => {
|
||||
it("should use base UID (not instance UID) when updating series with property changes", async () => {
|
||||
const masterEvent = {
|
||||
uid: baseUID,
|
||||
title: "Weekly Meeting",
|
||||
description: "Original description",
|
||||
calId,
|
||||
start: "2025-01-15T10:00:00.000Z",
|
||||
end: "2025-01-15T11:00:00.000Z",
|
||||
repetition: { freq: "weekly", interval: 1 },
|
||||
allday: false,
|
||||
organizer: { cn: "test", cal_address: "test@test.com" },
|
||||
URL: `/calendars/${calId}/${baseUID}.ics`,
|
||||
} as CalendarEvent;
|
||||
|
||||
const instance1 = {
|
||||
...masterEvent,
|
||||
uid: `${baseUID}/20250115`,
|
||||
};
|
||||
|
||||
const instance2 = {
|
||||
...masterEvent,
|
||||
uid: `${baseUID}/20250122`,
|
||||
start: "2025-01-22T10:00:00.000Z",
|
||||
end: "2025-01-22T11:00:00.000Z",
|
||||
};
|
||||
|
||||
const stateWithSeries = {
|
||||
...preloadedState,
|
||||
calendars: {
|
||||
...preloadedState.calendars,
|
||||
list: {
|
||||
[calId]: {
|
||||
...preloadedState.calendars.list[calId],
|
||||
events: {
|
||||
[baseUID]: masterEvent,
|
||||
[`${baseUID}/20250115`]: instance1,
|
||||
[`${baseUID}/20250122`]: instance2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Mock getEvent to return master
|
||||
jest.spyOn(EventApi, "getEvent").mockResolvedValue(masterEvent);
|
||||
const updateSeriesAsyncSpy = jest.spyOn(eventThunks, "updateSeriesAsync");
|
||||
|
||||
renderWithProviders(
|
||||
<EventUpdateModal
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={calId}
|
||||
eventId={`${baseUID}/20250115`}
|
||||
typeOfAction="all"
|
||||
/>,
|
||||
stateWithSeries
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("Weekly Meeting")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change only the title (property change, not recurrence rules)
|
||||
const titleInput = screen.getByDisplayValue("Weekly Meeting");
|
||||
fireEvent.change(titleInput, { target: { value: "Updated Meeting" } });
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "actions.save" });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton);
|
||||
});
|
||||
|
||||
// Verify updateSeriesAsync was dispatched with base UID
|
||||
await waitFor(() => {
|
||||
expect(updateSeriesAsyncSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: expect.objectContaining({
|
||||
uid: "recurring-event-base",
|
||||
recurrenceId: undefined,
|
||||
}),
|
||||
removeOverrides: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should use base UID when updating series with recurrence rule changes", async () => {
|
||||
const masterEvent = {
|
||||
uid: baseUID,
|
||||
title: "Daily Standup",
|
||||
calId,
|
||||
start: "2025-01-15T09:00:00.000Z",
|
||||
end: "2025-01-15T09:30:00.000Z",
|
||||
repetition: { freq: "daily", interval: 1 }, // Daily
|
||||
allday: false,
|
||||
timezone: "America/New_York",
|
||||
organizer: { cn: "test", cal_address: "test@test.com" },
|
||||
URL: `/calendars/${calId}/${baseUID}.ics`,
|
||||
} as CalendarEvent;
|
||||
|
||||
const instance1 = {
|
||||
...masterEvent,
|
||||
uid: `${baseUID}/20250115`,
|
||||
};
|
||||
|
||||
const instance2 = {
|
||||
...masterEvent,
|
||||
uid: `${baseUID}/20250116`,
|
||||
start: "2025-01-16T09:00:00.000Z",
|
||||
end: "2025-01-16T09:30:00.000Z",
|
||||
};
|
||||
|
||||
const stateWithSeries = {
|
||||
...preloadedState,
|
||||
calendars: {
|
||||
...preloadedState.calendars,
|
||||
list: {
|
||||
[calId]: {
|
||||
...preloadedState.calendars.list[calId],
|
||||
events: {
|
||||
[baseUID]: masterEvent,
|
||||
[`${baseUID}/20250115`]: instance1,
|
||||
[`${baseUID}/20250116`]: instance2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(EventApi, "getEvent").mockResolvedValue(masterEvent);
|
||||
jest.spyOn(CalendarApi, "getCalendar").mockResolvedValue({
|
||||
_embedded: { "dav:item": [] },
|
||||
} as any);
|
||||
|
||||
const { store } = renderWithProviders(
|
||||
<EventUpdateModal
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={calId}
|
||||
eventId={`${baseUID}/20250115`}
|
||||
typeOfAction="all"
|
||||
/>,
|
||||
stateWithSeries
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("Daily Standup")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click More Options to access repeat settings
|
||||
const moreOptionsButton = screen.getByText("common.moreOptions");
|
||||
fireEvent.click(moreOptionsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const repeatCheckbox = screen.getByLabelText("event.form.repeat");
|
||||
expect(repeatCheckbox).toBeInTheDocument();
|
||||
expect(repeatCheckbox).toBeChecked();
|
||||
});
|
||||
|
||||
// Change from daily to weekly
|
||||
const frequencySelect = screen.getByText("event.repeat.frequency.days");
|
||||
fireEvent.mouseDown(frequencySelect);
|
||||
const weeklyOption = screen.getByRole("option", {
|
||||
name: "event.repeat.frequency.weeks",
|
||||
});
|
||||
fireEvent.click(weeklyOption);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "actions.save" });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
// Verify all old instances were removed from store
|
||||
await waitFor(() => {
|
||||
const state = store.getState();
|
||||
const calendar = state.calendars.list[calId];
|
||||
|
||||
expect(calendar.events[baseUID]).toBeUndefined();
|
||||
expect(calendar.events[`${baseUID}/20250115`]).toBeUndefined();
|
||||
expect(calendar.events[`${baseUID}/20250116`]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Master Event Date Preservation", () => {
|
||||
it("should preserve master event date when updating time in 'all events' mode", async () => {
|
||||
// Master event starts on Jan 15 at 10:00 AM
|
||||
const masterEvent = {
|
||||
uid: baseUID,
|
||||
title: "Daily Standup",
|
||||
calId,
|
||||
start: "2025-01-15T10:00:00.000Z",
|
||||
end: "2025-01-15T10:30:00.000Z",
|
||||
repetition: { freq: "daily", interval: 1 },
|
||||
allday: false,
|
||||
timezone: "UTC",
|
||||
organizer: { cn: "test", cal_address: "test@test.com" },
|
||||
URL: `/calendars/${calId}/${baseUID}.ics`,
|
||||
} as CalendarEvent;
|
||||
|
||||
// Instance on Jan 17 (different date)
|
||||
const instance = {
|
||||
...masterEvent,
|
||||
uid: `${baseUID}/20250117`,
|
||||
start: "2025-01-17T10:00:00.000Z",
|
||||
end: "2025-01-17T10:30:00.000Z",
|
||||
};
|
||||
|
||||
const stateWithSeries = {
|
||||
...preloadedState,
|
||||
calendars: {
|
||||
...preloadedState.calendars,
|
||||
list: {
|
||||
[calId]: {
|
||||
...preloadedState.calendars.list[calId],
|
||||
events: {
|
||||
[baseUID]: masterEvent,
|
||||
[`${baseUID}/20250117`]: instance,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(EventApi, "getEvent").mockResolvedValue(masterEvent);
|
||||
jest
|
||||
.spyOn(EventApi, "putEvent")
|
||||
.mockResolvedValue({ status: 201 } as any);
|
||||
|
||||
const updateSeriesAsyncSpy = jest.spyOn(eventThunks, "updateSeriesAsync");
|
||||
|
||||
renderWithProviders(
|
||||
<EventUpdateModal
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
calId={calId}
|
||||
eventId={`${baseUID}/20250117`} // User clicked on Jan 17 instance
|
||||
typeOfAction="all"
|
||||
/>,
|
||||
stateWithSeries
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("Daily Standup")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change time to 2:00 PM (14:00)
|
||||
const input = screen.getByTestId("start-time-input");
|
||||
await userEvent.click(input);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "14:00{enter}");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "actions.save" });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
// Verify the API was called with master date (Jan 15) + new time (14:00)
|
||||
await waitFor(() => {
|
||||
expect(updateSeriesAsyncSpy).toHaveBeenCalled();
|
||||
const callArgs = updateSeriesAsyncSpy.mock.calls[0][0];
|
||||
const updatedEvent = callArgs.event;
|
||||
|
||||
// Should preserve Jan 15 date (master), not Jan 17 (clicked instance)
|
||||
expect(updatedEvent.start).toContain("2025-01-15");
|
||||
// But with new time 14:00 (in UTC, this is 14:00)
|
||||
expect(updatedEvent.start).toContain("14:00");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as eventUtils from "@/components/Event/utils/eventUtils";
|
||||
import * as CalendarApi from "@/features/Calendars/CalendarApi";
|
||||
import * as EventApi from "@/features/Events/EventApi";
|
||||
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||
import EventUpdateModal from "@/features/Events/EventUpdateModal";
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { renderWithProviders } from "../../utils/Renderwithproviders";
|
||||
@@ -257,6 +257,9 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
|
||||
status: 201,
|
||||
url: `/calendars/${calId}/new-event.ics`,
|
||||
} as any);
|
||||
const mockGetMasterEvent = jest
|
||||
.spyOn(EventApi, "getEvent")
|
||||
.mockResolvedValue(masterEvent);
|
||||
jest.spyOn(CalendarApi, "getCalendar").mockResolvedValue({
|
||||
_embedded: {
|
||||
"dav:item": [],
|
||||
@@ -274,28 +277,37 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
|
||||
stateWithRecurringEvent
|
||||
);
|
||||
|
||||
// Wait for component to load
|
||||
// Wait for master event to be fetched AND form to be initialized
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("Recurring Meeting")).toBeInTheDocument();
|
||||
expect(mockGetMasterEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Uncheck repeat checkbox
|
||||
const repeatCheckbox = screen.getByLabelText("event.form.repeat");
|
||||
expect(repeatCheckbox).toBeChecked();
|
||||
// Wait for the repeat checkbox to be checked (indicating form is initialized)
|
||||
const repeatCheckbox = await waitFor(() => {
|
||||
const checkbox = screen.getByLabelText("event.form.repeat");
|
||||
expect(checkbox).toBeChecked();
|
||||
return checkbox;
|
||||
});
|
||||
|
||||
// Now uncheck repeat checkbox
|
||||
await act(async () => {
|
||||
fireEvent.click(repeatCheckbox);
|
||||
});
|
||||
|
||||
expect(repeatCheckbox).not.toBeChecked();
|
||||
// Wait for checkbox to be unchecked
|
||||
await waitFor(() => {
|
||||
expect(repeatCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
// Click Save button
|
||||
const saveButton = screen.getByRole("button", { name: "actions.save" });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton);
|
||||
// Wait for the 500ms delay in the code
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPutEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify API calls - should delete all instances
|
||||
@@ -346,7 +358,7 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
|
||||
organizer: { cn: "test", cal_address: "test@test.com" },
|
||||
attendee: [{ cn: "test", cal_address: "test@test.com" }],
|
||||
URL: `/calendars/${calId}/${baseUID}.ics`,
|
||||
};
|
||||
} as CalendarEvent;
|
||||
|
||||
const instance1 = {
|
||||
...masterEvent,
|
||||
@@ -369,17 +381,17 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
|
||||
},
|
||||
};
|
||||
|
||||
// Mock deleteEvent to fail
|
||||
jest
|
||||
// Mock deleteEvent to fail with a non-404 error
|
||||
const mockDeleteEvent = jest
|
||||
.spyOn(EventApi, "deleteEvent")
|
||||
.mockRejectedValue(new Error("Network error"));
|
||||
const mockPutEvent = jest
|
||||
.spyOn(EventApi, "putEvent")
|
||||
.mockResolvedValue({ status: 201 } as any);
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
|
||||
|
||||
// Mock refreshCalendars
|
||||
jest.spyOn(eventUtils, "refreshCalendars").mockResolvedValue(undefined);
|
||||
const mockGetMasterEvent = jest
|
||||
.spyOn(EventApi, "getEvent")
|
||||
.mockResolvedValue(masterEvent);
|
||||
|
||||
const { store } = renderWithProviders(
|
||||
<EventUpdateModal
|
||||
@@ -392,25 +404,47 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
|
||||
stateWithRecurringEvent
|
||||
);
|
||||
|
||||
// Wait for master event to be fetched
|
||||
await waitFor(() => {
|
||||
expect(mockGetMasterEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for component to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("Recurring Meeting")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Uncheck repeat checkbox and save
|
||||
const repeatCheckbox = screen.getByLabelText("event.form.repeat");
|
||||
// Wait for repeat checkbox to be checked
|
||||
const repeatCheckbox = await waitFor(() => {
|
||||
const checkbox = screen.getByLabelText("event.form.repeat");
|
||||
expect(checkbox).toBeChecked();
|
||||
return checkbox;
|
||||
});
|
||||
|
||||
// Uncheck repeat checkbox and save
|
||||
await act(async () => {
|
||||
fireEvent.click(repeatCheckbox);
|
||||
});
|
||||
|
||||
// Wait for checkbox to be unchecked
|
||||
await waitFor(() => {
|
||||
expect(repeatCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "actions.save" });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton);
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
});
|
||||
|
||||
// Wait for delete to be attempted
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockDeleteEvent).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
|
||||
// Verify error was logged for failed deletion
|
||||
await waitFor(
|
||||
() => {
|
||||
@@ -523,7 +557,6 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton);
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
});
|
||||
|
||||
// Verify onCloseAll was called to close both preview and update modals
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("parseCalendarEvent", () => {
|
||||
expect(result.transp).toBe("OPAQUE");
|
||||
expect(result.class).toBe("PUBLIC");
|
||||
expect(result.x_openpass_videoconference).toBe("https://meet.link");
|
||||
expect(result.timezone).toBe("Etc/UTC");
|
||||
expect(result.timezone).not.toBeDefined();
|
||||
|
||||
expect(result.organizer).toEqual({
|
||||
cn: "Alice",
|
||||
@@ -117,7 +117,7 @@ describe("parseCalendarEvent", () => {
|
||||
expect(result.transp).toBe("OPAQUE");
|
||||
expect(result.class).toBe("PUBLIC");
|
||||
expect(result.x_openpass_videoconference).toBe("https://meet.link");
|
||||
expect(result.timezone).toBe("Etc/UTC");
|
||||
expect(result.timezone).not.toBeDefined();
|
||||
|
||||
expect(result.organizer).toEqual({
|
||||
cn: "Alice",
|
||||
@@ -210,7 +210,6 @@ describe("parseCalendarEvent", () => {
|
||||
"/calendars/test.ics"
|
||||
);
|
||||
expect(result.end).toBeDefined();
|
||||
expect(result.timezone).toBeDefined();
|
||||
const endDate = new Date(result.end ?? "");
|
||||
const startDate = new Date(result.start);
|
||||
expect(endDate.getTime() - startDate.getTime()).toBe(60 * 60 * 1000);
|
||||
@@ -229,7 +228,6 @@ describe("parseCalendarEvent", () => {
|
||||
"/calendars/test.ics"
|
||||
);
|
||||
expect(result.end).toBeDefined();
|
||||
expect(result.timezone).toBeDefined();
|
||||
const endDate = new Date(result.end ?? "");
|
||||
const startDate = new Date(result.start);
|
||||
expect(endDate.getTime() - startDate.getTime()).toBe(30 * 60 * 1000);
|
||||
@@ -332,7 +330,6 @@ describe("parseCalendarEvent", () => {
|
||||
|
||||
expect(result.start).toBe("2025-07-18T09:00:00Z");
|
||||
expect(result.end).toBe("2025-07-18T10:00:00Z");
|
||||
expect(result.timezone).toBe("Etc/UTC");
|
||||
});
|
||||
|
||||
it("preserves all-day event dates without conversion", () => {
|
||||
@@ -356,15 +353,6 @@ describe("parseCalendarEvent", () => {
|
||||
});
|
||||
|
||||
describe("calendarEventToJCal", () => {
|
||||
beforeAll(() => {
|
||||
jest.mock("ical.js", () => ({
|
||||
Timezone: jest.fn().mockImplementation(({ component, tzid }) => ({
|
||||
component: {
|
||||
jCal: [`vtimezone`, [], [["tzid", {}, "text", tzid]]],
|
||||
},
|
||||
})),
|
||||
}));
|
||||
});
|
||||
it("should convert a CalendarEvent to JCal format", () => {
|
||||
const mockEvent = {
|
||||
uid: "event-123",
|
||||
@@ -1165,8 +1153,7 @@ describe("detectRecurringEventChanges", () => {
|
||||
end: "2025-10-15T10:00",
|
||||
},
|
||||
null,
|
||||
mockResolveTimezone,
|
||||
mockFormatDateTime
|
||||
mockResolveTimezone
|
||||
);
|
||||
|
||||
expect(result.timeChanged).toBe(true);
|
||||
@@ -1192,8 +1179,7 @@ describe("detectRecurringEventChanges", () => {
|
||||
end: "2025-10-15T08:00",
|
||||
},
|
||||
null,
|
||||
mockResolveTimezone,
|
||||
mockFormatDateTime
|
||||
mockResolveTimezone
|
||||
);
|
||||
|
||||
expect(result.timeChanged).toBe(false);
|
||||
@@ -1218,8 +1204,7 @@ describe("detectRecurringEventChanges", () => {
|
||||
end: "2025-10-15T08:00",
|
||||
},
|
||||
null,
|
||||
mockResolveTimezone,
|
||||
mockFormatDateTime
|
||||
mockResolveTimezone
|
||||
);
|
||||
|
||||
expect(result.timezoneChanged).toBe(true);
|
||||
@@ -1249,8 +1234,7 @@ describe("detectRecurringEventChanges", () => {
|
||||
end: "2025-10-15T08:00",
|
||||
},
|
||||
null,
|
||||
resolveWithAlias,
|
||||
mockFormatDateTime
|
||||
resolveWithAlias
|
||||
);
|
||||
|
||||
expect(result.timezoneChanged).toBe(false);
|
||||
@@ -1274,8 +1258,7 @@ describe("detectRecurringEventChanges", () => {
|
||||
end: "2025-10-15T08:00",
|
||||
},
|
||||
null,
|
||||
mockResolveTimezone,
|
||||
mockFormatDateTime
|
||||
mockResolveTimezone
|
||||
);
|
||||
|
||||
expect(result.repetitionRulesChanged).toBe(true);
|
||||
@@ -1299,8 +1282,7 @@ describe("detectRecurringEventChanges", () => {
|
||||
end: "2025-10-15T08:00",
|
||||
},
|
||||
null,
|
||||
mockResolveTimezone,
|
||||
mockFormatDateTime
|
||||
mockResolveTimezone
|
||||
);
|
||||
|
||||
expect(result.repetitionRulesChanged).toBe(true);
|
||||
@@ -1332,8 +1314,7 @@ describe("detectRecurringEventChanges", () => {
|
||||
end: "2025-10-15T08:00",
|
||||
},
|
||||
null,
|
||||
mockResolveTimezone,
|
||||
mockFormatDateTime
|
||||
mockResolveTimezone
|
||||
);
|
||||
|
||||
expect(result.repetitionRulesChanged).toBe(true);
|
||||
@@ -1357,8 +1338,7 @@ describe("detectRecurringEventChanges", () => {
|
||||
end: "2025-10-15T10:00",
|
||||
},
|
||||
null,
|
||||
mockResolveTimezone,
|
||||
mockFormatDateTime
|
||||
mockResolveTimezone
|
||||
);
|
||||
|
||||
expect(result.timeChanged).toBe(true);
|
||||
|
||||
@@ -193,9 +193,30 @@ const CalendarSlice = createSlice({
|
||||
events: {},
|
||||
} as Calendar;
|
||||
}
|
||||
|
||||
state.list[action.payload.calId].events[action.payload.event.uid] =
|
||||
action.payload.event;
|
||||
if (
|
||||
Object.keys(state.list[action.payload.calId].events).find(
|
||||
(eventId: string) => {
|
||||
const eventIdBase = extractEventBaseUuid(eventId);
|
||||
return eventIdBase === action.payload.event.uid;
|
||||
}
|
||||
)
|
||||
) {
|
||||
Object.keys(state.list[action.payload.calId].events)
|
||||
.filter((eventKey) => {
|
||||
const baseUid = extractEventBaseUuid(eventKey);
|
||||
return baseUid === action.payload.event.uid;
|
||||
})
|
||||
.forEach((eventKey) => {
|
||||
state.list[action.payload.calId].events[eventKey] = {
|
||||
...state.list[action.payload.calId].events[eventKey],
|
||||
repetition: action.payload.event.repetition,
|
||||
timezone: action.payload.event.timezone,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
state.list[action.payload.calId].events[action.payload.event.uid] =
|
||||
action.payload.event;
|
||||
}
|
||||
}
|
||||
)
|
||||
.addCase(moveEventAsync.fulfilled, (state) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { api } from "@/utils/apiUtils";
|
||||
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
|
||||
import { convertEventDateTimeToISO, resolveTimezoneId } from "@/utils/timezone";
|
||||
import { resolveTimezoneId, convertEventDateTimeToISO } from "@/utils/timezone";
|
||||
import { TIMEZONES } from "@/utils/timezone-data";
|
||||
import ICAL from "ical.js";
|
||||
import { CalDavItem } from "../Calendars/api/types";
|
||||
@@ -203,17 +202,72 @@ export const deleteEventInstance = async (
|
||||
event: CalendarEvent,
|
||||
calOwnerEmail?: string
|
||||
) => {
|
||||
const seriesEvent = await getEvent(
|
||||
{
|
||||
...event,
|
||||
uid: extractEventBaseUuid(event.uid),
|
||||
},
|
||||
true
|
||||
);
|
||||
seriesEvent.exdates = [...(seriesEvent.exdates || []), event.start];
|
||||
delete seriesEvent.recurrenceId;
|
||||
// Get all VEVENTs (master + overrides) from the series
|
||||
const vevents = await getAllRecurrentEvent(event);
|
||||
|
||||
return putEvent(seriesEvent, calOwnerEmail);
|
||||
// Find the master VEVENT
|
||||
const masterIndex = vevents.findIndex(
|
||||
([, props]: [string, any[]]) =>
|
||||
!props.find(([k]: string[]) => k.toLowerCase() === "recurrence-id")
|
||||
);
|
||||
|
||||
if (masterIndex === -1) {
|
||||
throw new Error("No master VEVENT found for this series");
|
||||
}
|
||||
|
||||
const exdateValue = event.recurrenceId || event.start;
|
||||
const seriesEvent = parseCalendarEvent(vevents[masterIndex][1], {}, "", "");
|
||||
const masterProps = vevents[masterIndex][1];
|
||||
|
||||
// Check if this date is already in EXDATE (avoid duplicates)
|
||||
const normalizeRecurrenceId = (id: string) => (id ?? "").replace(/Z$/, "");
|
||||
const isDuplicate = masterProps.some((prop: any[]) => {
|
||||
if (prop[0].toLowerCase() === "exdate" && prop[3]) {
|
||||
return (
|
||||
normalizeRecurrenceId(prop[3]) === normalizeRecurrenceId(exdateValue)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!isDuplicate) {
|
||||
// Add new EXDATE property as a separate entry
|
||||
const valueType = seriesEvent.allday ? "date" : "date-time";
|
||||
masterProps.push(["exdate", {}, valueType, exdateValue]);
|
||||
}
|
||||
|
||||
// Update the master VEVENT with the new properties
|
||||
vevents[masterIndex][1] = masterProps;
|
||||
|
||||
// Remove the override instance if it exists (in case it was an override being deleted)
|
||||
const filteredVevents = vevents.filter(([, props]: [string, any[]]) => {
|
||||
const recurrenceIdProp = props.find(
|
||||
([k]: string[]) => k.toLowerCase() === "recurrence-id"
|
||||
);
|
||||
if (!recurrenceIdProp) return true; // Keep master
|
||||
return (
|
||||
normalizeRecurrenceId(recurrenceIdProp[3]) !==
|
||||
normalizeRecurrenceId(event.recurrenceId ?? "")
|
||||
); // Remove matching override
|
||||
});
|
||||
|
||||
// Build the updated jCal with all VEVENTs and timezone
|
||||
const timezoneData = TIMEZONES.zones[seriesEvent.timezone];
|
||||
const vtimezone = makeTimezone(timezoneData, seriesEvent);
|
||||
|
||||
const newJCal = [
|
||||
"vcalendar",
|
||||
[],
|
||||
[...filteredVevents, vtimezone.component.jCal],
|
||||
];
|
||||
|
||||
return api(`dav${event.URL}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(newJCal),
|
||||
headers: {
|
||||
"content-type": "text/calendar; charset=utf-8",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const updateSeriesPartstat = async (
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { useAppDispatch, useAppSelector } from "@/app/hooks";
|
||||
import { updateAttendeesAfterTimeChange } from "@/components/Calendar/handlers/eventHandlers";
|
||||
import { updateTempCalendar } from "@/components/Calendar/utils/calendarUtils";
|
||||
import { ResponsiveDialog } from "@/components/Dialog";
|
||||
import EventFormFields from "@/components/Event/EventFormFields";
|
||||
import { addDays } from "@/components/Event/utils/dateRules";
|
||||
import { formatDateTimeInTimezone } from "@/components/Event/utils/dateTimeFormatters";
|
||||
import { convertFormDateTimeToISO } from "@/components/Event/utils/dateTimeHelpers";
|
||||
import { refreshCalendars } from "@/components/Event/utils/eventUtils";
|
||||
import {
|
||||
moveEventAsync,
|
||||
putEventAsync,
|
||||
updateEventInstanceAsync,
|
||||
updateSeriesAsync,
|
||||
} from "@/features/Calendars/services";
|
||||
import { getCalendarRange } from "@/utils/dateUtils";
|
||||
import {
|
||||
buildEventFormTempData,
|
||||
clearEventFormTempData,
|
||||
@@ -45,10 +42,7 @@ import { Calendar } from "../Calendars/CalendarTypes";
|
||||
import { userAttendee } from "../User/models/attendee";
|
||||
import { deleteEvent, getEvent, putEvent } from "./EventApi";
|
||||
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
||||
import {
|
||||
combineMasterDateWithFormTime,
|
||||
detectRecurringEventChanges,
|
||||
} from "./eventUtils";
|
||||
import { detectRecurringEventChanges } from "./eventUtils";
|
||||
|
||||
function EventUpdateModal({
|
||||
eventId,
|
||||
@@ -75,31 +69,8 @@ function EventUpdateModal({
|
||||
(state) => state.calendars.list[calId]?.events[eventId]
|
||||
);
|
||||
|
||||
// State for fresh event data
|
||||
const [freshEvent, setFreshEvent] = useState<CalendarEvent | null>(null);
|
||||
|
||||
// Use fresh data if available, otherwise use eventData from props, otherwise use cached data
|
||||
const event = freshEvent || eventData || cachedEvent;
|
||||
|
||||
useEffect(() => {
|
||||
setFreshEvent(null);
|
||||
}, [eventId, calId]);
|
||||
|
||||
// Fetch fresh event data when modal opens
|
||||
useEffect(() => {
|
||||
if (open && cachedEvent && !eventData) {
|
||||
const fetchFreshData = async () => {
|
||||
try {
|
||||
const freshData = await getEvent(cachedEvent);
|
||||
setFreshEvent(freshData);
|
||||
} catch (err) {
|
||||
// Keep using cached data if API fails
|
||||
}
|
||||
};
|
||||
|
||||
fetchFreshData();
|
||||
}
|
||||
}, [open, cachedEvent, eventData]);
|
||||
const event = eventData || cachedEvent;
|
||||
|
||||
const user = useAppSelector((state) => state.user);
|
||||
|
||||
@@ -186,53 +157,112 @@ function EventUpdateModal({
|
||||
// Track when restoring from error to prevent other useEffects from overriding restored data
|
||||
const isRestoringFromErrorRef = useRef(false);
|
||||
|
||||
// State to hold the master event when editing "all events"
|
||||
const [masterEvent, setMasterEvent] = useState<CalendarEvent | null>(null);
|
||||
const [isLoadingMasterEvent, setIsLoadingMasterEvent] = useState(false);
|
||||
|
||||
// Fetch master event when editing "all events" of a recurring series
|
||||
useEffect(() => {
|
||||
if (!event || !open || typeOfAction !== "all") {
|
||||
setMasterEvent(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const isRecurringEvent = !!event.repetition?.freq;
|
||||
if (!isRecurringEvent) {
|
||||
setMasterEvent(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an instance (has recurrence-id)
|
||||
const [baseUID, recurrenceId] = event.uid.split("/");
|
||||
if (!recurrenceId) {
|
||||
// This is already the master event
|
||||
setMasterEvent(event);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the master event
|
||||
const fetchMasterEvent = async () => {
|
||||
setIsLoadingMasterEvent(true);
|
||||
try {
|
||||
const masterEventToFetch = {
|
||||
...event,
|
||||
uid: baseUID, // Use base UID to get master event
|
||||
};
|
||||
const fetchedMasterEvent = await getEvent(masterEventToFetch, true);
|
||||
setMasterEvent(fetchedMasterEvent);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to fetch master event:", err);
|
||||
// Fallback to using the clicked instance
|
||||
setMasterEvent(event);
|
||||
} finally {
|
||||
setIsLoadingMasterEvent(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMasterEvent();
|
||||
}, [event, open, typeOfAction]);
|
||||
|
||||
// Initialize form state when event data is available
|
||||
useEffect(() => {
|
||||
// Skip if restoring from error - data already restored
|
||||
if (isRestoringFromErrorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if still loading master event
|
||||
if (isLoadingMasterEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event && open) {
|
||||
// Use master event for "all" action, otherwise use the clicked event
|
||||
const eventToDisplay =
|
||||
typeOfAction === "all" && masterEvent ? masterEvent : event;
|
||||
|
||||
// Reset validation errors when modal opens
|
||||
setShowValidationErrors(false);
|
||||
|
||||
// Editing existing event - populate fields with event data
|
||||
setTitle(event.title ?? "");
|
||||
setDescription(event.description ?? "");
|
||||
setLocation(event.location ?? "");
|
||||
setTitle(eventToDisplay.title ?? "");
|
||||
setDescription(eventToDisplay.description ?? "");
|
||||
setLocation(eventToDisplay.location ?? "");
|
||||
|
||||
// Handle all-day events properly
|
||||
const isAllDay = event.allday ?? false;
|
||||
const isAllDay = eventToDisplay.allday ?? false;
|
||||
setAllDay(isAllDay);
|
||||
|
||||
// Get event's original timezone
|
||||
const eventTimezone = event.timezone
|
||||
? resolveTimezone(event.timezone)
|
||||
const eventTimezone = eventToDisplay.timezone
|
||||
? resolveTimezone(eventToDisplay.timezone)
|
||||
: resolveTimezone(browserDefaultTimeZone);
|
||||
|
||||
// Format dates based on all-day status
|
||||
if (event.start) {
|
||||
if (eventToDisplay.start) {
|
||||
if (isAllDay) {
|
||||
// For all-day events, use date format (YYYY-MM-DD)
|
||||
const startDate = new Date(event.start);
|
||||
const startDate = new Date(eventToDisplay.start);
|
||||
setStart(startDate.toISOString().split("T")[0]);
|
||||
} else {
|
||||
// For timed events, format in the event's original timezone
|
||||
setStart(formatDateTimeInTimezone(event.start, eventTimezone));
|
||||
setStart(
|
||||
formatDateTimeInTimezone(eventToDisplay.start, eventTimezone)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setStart("");
|
||||
}
|
||||
|
||||
if (event.end) {
|
||||
if (eventToDisplay.end) {
|
||||
if (isAllDay) {
|
||||
// For all-day events, use date format (YYYY-MM-DD)
|
||||
const endDate = new Date(event.end);
|
||||
const endDate = new Date(eventToDisplay.end);
|
||||
endDate.setDate(endDate.getDate() - 1);
|
||||
setEnd(endDate.toISOString().split("T")[0]);
|
||||
} else {
|
||||
// For timed events, format in the event's original timezone
|
||||
setEnd(formatDateTimeInTimezone(event.end, eventTimezone));
|
||||
setEnd(formatDateTimeInTimezone(eventToDisplay.end, eventTimezone));
|
||||
}
|
||||
} else {
|
||||
setEnd("");
|
||||
@@ -242,9 +272,10 @@ function EventUpdateModal({
|
||||
setCalendarid(calId);
|
||||
|
||||
// Handle repetition properly - check both current event and base event
|
||||
const baseEventId = extractEventBaseUuid(event.uid);
|
||||
const baseEventId = extractEventBaseUuid(eventToDisplay.uid);
|
||||
const baseEvent = calendarsList[calId]?.events[baseEventId];
|
||||
const repetitionSource = event.repetition || baseEvent?.repetition;
|
||||
const repetitionSource =
|
||||
eventToDisplay.repetition || baseEvent?.repetition;
|
||||
|
||||
if (repetitionSource && repetitionSource.freq) {
|
||||
const repetitionData: RepetitionObject = {
|
||||
@@ -262,44 +293,57 @@ function EventUpdateModal({
|
||||
}
|
||||
|
||||
setAttendees(
|
||||
event.attendee
|
||||
? event.attendee.filter(
|
||||
eventToDisplay.attendee
|
||||
? eventToDisplay.attendee.filter(
|
||||
(a: userAttendee) =>
|
||||
a.cal_address !== event.organizer?.cal_address
|
||||
a.cal_address !== eventToDisplay.organizer?.cal_address
|
||||
)
|
||||
: []
|
||||
);
|
||||
setAlarm(event.alarm?.trigger ?? "");
|
||||
setEventClass(event.class ?? "PUBLIC");
|
||||
setBusy(event.transp ?? "OPAQUE");
|
||||
setAlarm(eventToDisplay.alarm?.trigger ?? "");
|
||||
setEventClass(eventToDisplay.class ?? "PUBLIC");
|
||||
setBusy(eventToDisplay.transp ?? "OPAQUE");
|
||||
|
||||
if (event.timezone) {
|
||||
const resolvedTimezone = resolveTimezone(event.timezone);
|
||||
if (eventToDisplay.timezone) {
|
||||
const resolvedTimezone = resolveTimezone(eventToDisplay.timezone);
|
||||
setTimezone(resolvedTimezone);
|
||||
} else {
|
||||
const browserTz = resolveTimezone(browserDefaultTimeZone);
|
||||
setTimezone(browserTz);
|
||||
}
|
||||
setHasVideoConference(event.x_openpass_videoconference ? true : false);
|
||||
setMeetingLink(event.x_openpass_videoconference || null);
|
||||
setNewCalId(event.calId || calId);
|
||||
setHasVideoConference(
|
||||
eventToDisplay.x_openpass_videoconference ? true : false
|
||||
);
|
||||
setMeetingLink(eventToDisplay.x_openpass_videoconference || null);
|
||||
setNewCalId(eventToDisplay.calId || calId);
|
||||
|
||||
// Update description to include video conference footer if exists
|
||||
if (event.x_openpass_videoconference && event.description) {
|
||||
const hasVideoFooter = event.description.includes("Visio:");
|
||||
if (
|
||||
eventToDisplay.x_openpass_videoconference &&
|
||||
eventToDisplay.description
|
||||
) {
|
||||
const hasVideoFooter = eventToDisplay.description.includes("Visio:");
|
||||
if (!hasVideoFooter) {
|
||||
setDescription(
|
||||
addVideoConferenceToDescription(
|
||||
event.description,
|
||||
event.x_openpass_videoconference
|
||||
eventToDisplay.description,
|
||||
eventToDisplay.x_openpass_videoconference
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setDescription(event.description);
|
||||
setDescription(eventToDisplay.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [open, event, calId, userPersonalCalendars, calendarsList]);
|
||||
}, [
|
||||
open,
|
||||
event,
|
||||
calId,
|
||||
userPersonalCalendars,
|
||||
calendarsList,
|
||||
masterEvent,
|
||||
isLoadingMasterEvent,
|
||||
]);
|
||||
|
||||
// Helper to close modal(s) - use onCloseAll if available to close preview modal too
|
||||
const closeModal = () => {
|
||||
@@ -316,7 +360,6 @@ function EventUpdateModal({
|
||||
closeModal();
|
||||
setShowValidationErrors(false);
|
||||
resetAllStateToDefault();
|
||||
setFreshEvent(null);
|
||||
initializedKeyRef.current = null;
|
||||
};
|
||||
|
||||
@@ -467,19 +510,14 @@ function EventUpdateModal({
|
||||
return instances;
|
||||
};
|
||||
|
||||
// When editing "all events" of a recurring series, fetch master event to get original start time
|
||||
// When editing "all events" of a recurring series, use the master event we already fetched
|
||||
let masterEventData: CalendarEvent | null = null;
|
||||
if (isRecurringEvent && typeOfAction === "all") {
|
||||
try {
|
||||
// Fetch master event using base UID (without recurrence-id)
|
||||
const masterEventToFetch = {
|
||||
...event,
|
||||
uid: baseUID, // Use base UID to get master event
|
||||
};
|
||||
const masterEvent = await getEvent(masterEventToFetch, true);
|
||||
masterEventData = masterEvent;
|
||||
} catch (err: any) {
|
||||
// API failed - restore form data and mark as error
|
||||
// We already have the master event from state
|
||||
masterEventData = masterEvent;
|
||||
|
||||
if (!masterEventData) {
|
||||
// This shouldn't happen, but handle it gracefully
|
||||
const formDataToSave = saveCurrentFormData();
|
||||
const errorFormData = {
|
||||
...formDataToSave,
|
||||
@@ -488,7 +526,7 @@ function EventUpdateModal({
|
||||
saveEventFormDataToTemp("update", errorFormData);
|
||||
|
||||
showErrorNotification(
|
||||
err?.message || "Failed to fetch event data. Please try again."
|
||||
"Failed to load master event data. Please try again."
|
||||
);
|
||||
|
||||
// Dispatch eventModalError to reopen modal
|
||||
@@ -505,58 +543,42 @@ function EventUpdateModal({
|
||||
let startDate: string;
|
||||
let endDate: string;
|
||||
|
||||
// For "all events" update, use master event's DATE but apply user's TIME from form
|
||||
if (masterEventData && typeOfAction === "all") {
|
||||
const combined = combineMasterDateWithFormTime(
|
||||
masterEventData,
|
||||
start,
|
||||
end,
|
||||
timezone,
|
||||
allday,
|
||||
formatDateTimeInTimezone
|
||||
// For single events or "solo" edits, use the edited dates from form
|
||||
if (allday) {
|
||||
// For all-day events, use date format (YYYY-MM-DD)
|
||||
// Extract date string directly to avoid timezone conversion issues
|
||||
const startDateOnly = (start || "").split("T")[0];
|
||||
const endDateOnlyUI = (end || start || "").split("T")[0];
|
||||
// API needs end date = UI end date + 1 day
|
||||
const endDateOnlyAPI = addDays(endDateOnlyUI, 1);
|
||||
// Parse date string and create Date at UTC midnight to avoid timezone offset issues
|
||||
const [startYear, startMonth, startDay] = startDateOnly
|
||||
.split("-")
|
||||
.map(Number);
|
||||
const [endYear, endMonth, endDay] = endDateOnlyAPI.split("-").map(Number);
|
||||
const startDateObj = new Date(
|
||||
Date.UTC(startYear, startMonth - 1, startDay, 0, 0, 0, 0)
|
||||
);
|
||||
startDate = combined.startDate;
|
||||
endDate = combined.endDate;
|
||||
const endDateObj = new Date(
|
||||
Date.UTC(endYear, endMonth - 1, endDay, 0, 0, 0, 0)
|
||||
);
|
||||
startDate = startDateObj.toISOString();
|
||||
endDate = endDateObj.toISOString();
|
||||
} else {
|
||||
// For single events or "solo" edits, use the edited dates from form
|
||||
if (allday) {
|
||||
// For all-day events, use date format (YYYY-MM-DD)
|
||||
// Extract date string directly to avoid timezone conversion issues
|
||||
const startDateOnly = (start || "").split("T")[0];
|
||||
const endDateOnlyUI = (end || start || "").split("T")[0];
|
||||
// API needs end date = UI end date + 1 day
|
||||
const endDateOnlyAPI = addDays(endDateOnlyUI, 1);
|
||||
// Parse date string and create Date at UTC midnight to avoid timezone offset issues
|
||||
const [startYear, startMonth, startDay] = startDateOnly
|
||||
.split("-")
|
||||
.map(Number);
|
||||
const [endYear, endMonth, endDay] = endDateOnlyAPI
|
||||
.split("-")
|
||||
.map(Number);
|
||||
const startDateObj = new Date(
|
||||
Date.UTC(startYear, startMonth - 1, startDay, 0, 0, 0, 0)
|
||||
);
|
||||
const endDateObj = new Date(
|
||||
Date.UTC(endYear, endMonth - 1, endDay, 0, 0, 0, 0)
|
||||
);
|
||||
startDate = startDateObj.toISOString();
|
||||
endDate = endDateObj.toISOString();
|
||||
// For timed events
|
||||
startDate = convertFormDateTimeToISO(start, timezone);
|
||||
// In normal mode, only override end date when the end date field is not shown and end date is same as start date
|
||||
const startDateOnly = (start || "").split("T")[0];
|
||||
const endDateOnly = (end || "").split("T")[0];
|
||||
if (!showMore && !hasEndDateChanged && startDateOnly === endDateOnly) {
|
||||
const endTimeOnly = end.includes("T")
|
||||
? end.split("T")[1]?.slice(0, 5) || "00:00"
|
||||
: "00:00";
|
||||
const endDateTime = `${startDateOnly}T${endTimeOnly}`;
|
||||
endDate = convertFormDateTimeToISO(endDateTime, timezone);
|
||||
} else {
|
||||
// For timed events
|
||||
startDate = convertFormDateTimeToISO(start, timezone);
|
||||
// In normal mode, only override end date when the end date field is not shown and end date is same as start date
|
||||
const startDateOnly = (start || "").split("T")[0];
|
||||
const endDateOnly = (end || "").split("T")[0];
|
||||
if (!showMore && !hasEndDateChanged && startDateOnly === endDateOnly) {
|
||||
const endTimeOnly = end.includes("T")
|
||||
? end.split("T")[1]?.slice(0, 5) || "00:00"
|
||||
: "00:00";
|
||||
const endDateTime = `${startDateOnly}T${endTimeOnly}`;
|
||||
endDate = convertFormDateTimeToISO(endDateTime, timezone);
|
||||
} else {
|
||||
// Extended mode or end date explicitly shown in normal mode or end date differs from start date: use actual end datetime
|
||||
endDate = convertFormDateTimeToISO(end, timezone);
|
||||
}
|
||||
// Extended mode or end date explicitly shown in normal mode or end date differs from start date: use actual end datetime
|
||||
endDate = convertFormDateTimeToISO(end, timezone);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,6 +702,7 @@ function EventUpdateModal({
|
||||
uid: newEventUID,
|
||||
URL: `/calendars/${newCalId || calId}/${newEventUID}.ics`,
|
||||
sequence: 1, // New event with new UID starts at sequence 1
|
||||
recurrenceId: undefined,
|
||||
};
|
||||
|
||||
// STEP 3: Persist new event to server
|
||||
@@ -700,7 +723,6 @@ function EventUpdateModal({
|
||||
|
||||
// Reset all state to default values only on successful save
|
||||
resetAllStateToDefault();
|
||||
setFreshEvent(null);
|
||||
initializedKeyRef.current = null;
|
||||
} catch (err: any) {
|
||||
// API failed - restore form data and mark as error
|
||||
@@ -801,8 +823,7 @@ function EventUpdateModal({
|
||||
event,
|
||||
{ repetition, timezone, allday, start, end },
|
||||
masterEventData,
|
||||
resolveTimezone,
|
||||
formatDateTimeInTimezone
|
||||
resolveTimezone
|
||||
);
|
||||
const repetitionRulesChanged = changes.repetitionRulesChanged;
|
||||
|
||||
@@ -834,11 +855,18 @@ function EventUpdateModal({
|
||||
// STEP 1: Remove ALL old instances from UI (including solo overrides)
|
||||
removeSeriesInstancesFromUI();
|
||||
|
||||
// STEP 2: Update series on server with removeOverrides=true (await to ensure it completes)
|
||||
// STEP 2: Update series on server with removeOverrides=true
|
||||
// IMPORTANT: Use base event UID (master), not instance UID with recurrence-id
|
||||
const masterEventForUpdate = {
|
||||
...newEvent,
|
||||
uid: baseUID, // Use base UID for updating the master
|
||||
recurrenceId: undefined, // Don't send recurrence-id for master update
|
||||
};
|
||||
|
||||
const result = await dispatch(
|
||||
updateSeriesAsync({
|
||||
cal: targetCalendar,
|
||||
event: { ...newEvent, recurrenceId },
|
||||
event: masterEventForUpdate,
|
||||
removeOverrides: true,
|
||||
})
|
||||
);
|
||||
@@ -865,12 +893,13 @@ function EventUpdateModal({
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cache after reload
|
||||
// Clear cache after successful update
|
||||
dispatch(clearFetchCache(calId));
|
||||
|
||||
// Clear temp data on successful save
|
||||
clearEventFormTempData("update");
|
||||
} catch (seriesError) {
|
||||
// Restore instances on error
|
||||
restoreSeriesInstancesFromSnapshot();
|
||||
throw seriesError;
|
||||
}
|
||||
@@ -904,10 +933,17 @@ function EventUpdateModal({
|
||||
});
|
||||
|
||||
// Update server in background with removeOverrides=false
|
||||
// IMPORTANT: Use base event UID (master), not instance UID with recurrence-id
|
||||
const masterEventForUpdate = {
|
||||
...newEvent,
|
||||
uid: baseUID, // Use base UID for updating the master
|
||||
recurrenceId: undefined, // Don't send recurrence-id for master update
|
||||
};
|
||||
|
||||
const result = await dispatch(
|
||||
updateSeriesAsync({
|
||||
cal: targetCalendar,
|
||||
event: { ...newEvent, recurrenceId },
|
||||
event: masterEventForUpdate,
|
||||
removeOverrides: false,
|
||||
})
|
||||
);
|
||||
@@ -1049,7 +1085,6 @@ function EventUpdateModal({
|
||||
// Reset all state to default values only on successful save (after all branches)
|
||||
clearEventFormTempData("update");
|
||||
resetAllStateToDefault();
|
||||
setFreshEvent(null);
|
||||
initializedKeyRef.current = null;
|
||||
} catch (error: any) {
|
||||
// Handle errors for all branches
|
||||
|
||||
@@ -14,9 +14,6 @@ function inferTimezoneFromValue(
|
||||
value: string
|
||||
): string | undefined {
|
||||
if (!params) {
|
||||
if (typeof value === "string" && value.endsWith("Z")) {
|
||||
return "Etc/UTC";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -29,11 +26,6 @@ function inferTimezoneFromValue(
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.endsWith("Z")) {
|
||||
return "Etc/UTC";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -186,8 +178,7 @@ export function parseCalendarEvent(
|
||||
event.error = `missing crucial event param in calendar ${calendarid} `;
|
||||
}
|
||||
|
||||
const eventTimezone = event.timezone || "Etc/UTC";
|
||||
event.timezone = eventTimezone;
|
||||
const eventTimezone = event.timezone;
|
||||
|
||||
if (!event.end) {
|
||||
const start = event.start ? new Date(event.start) : new Date();
|
||||
@@ -516,52 +507,38 @@ export function detectRecurringEventChanges(
|
||||
end: string;
|
||||
},
|
||||
masterEventData: CalendarEvent | null,
|
||||
resolveTimezone: (tz: string) => string,
|
||||
formatDateTimeInTimezone: (iso: string, tz: string) => string
|
||||
resolveTimezone: (tz: string) => string
|
||||
): {
|
||||
timeChanged: boolean;
|
||||
timezoneChanged: boolean;
|
||||
repetitionRulesChanged: boolean;
|
||||
} {
|
||||
const oldRepetition = normalizeRepetition(oldEvent.repetition);
|
||||
const newRepetition = normalizeRepetition(newData.repetition);
|
||||
|
||||
const oldTimezone = normalizeTimezone(oldEvent.timezone, resolveTimezone);
|
||||
const newTimezone = normalizeTimezone(newData.timezone, resolveTimezone);
|
||||
const oldTimezone = resolveTimezone(oldEvent.timezone || "UTC");
|
||||
const newTimezone = resolveTimezone(newData.timezone || "UTC");
|
||||
const timezoneChanged = oldTimezone !== newTimezone;
|
||||
|
||||
// Check if TIME changed (compare time portion only, not date)
|
||||
const extractTimeFromForm = (localDateTimeStr: string) => {
|
||||
if (!localDateTimeStr) return null;
|
||||
const timePart = localDateTimeStr.includes("T")
|
||||
? localDateTimeStr.split("T")[1]
|
||||
: localDateTimeStr.substring(11);
|
||||
return timePart?.substring(0, 5); // HH:MM
|
||||
};
|
||||
// Use master event as the source of truth for the "old" times,
|
||||
// falling back to the clicked instance if master isn't available.
|
||||
const oldStart = masterEventData?.start || oldEvent.start;
|
||||
const oldEnd = masterEventData?.end || oldEvent.end;
|
||||
|
||||
const extractTimeFromISO = (isoString: string | undefined, tz: string) => {
|
||||
if (!isoString) return null;
|
||||
const formatted = formatDateTimeInTimezone(isoString, tz);
|
||||
const timePart = formatted.includes("T")
|
||||
? formatted.split("T")[1]
|
||||
: formatted.substring(11);
|
||||
return timePart?.substring(0, 5); // HH:MM
|
||||
};
|
||||
|
||||
const masterOldStart = masterEventData?.start || oldEvent.start;
|
||||
const masterOldEnd = masterEventData?.end || oldEvent.end;
|
||||
|
||||
const formStartTime = extractTimeFromForm(newData.start);
|
||||
const formEndTime = extractTimeFromForm(newData.end);
|
||||
|
||||
const oldStartTime = extractTimeFromISO(masterOldStart, newData.timezone);
|
||||
const oldEndTime = extractTimeFromISO(masterOldEnd, newData.timezone);
|
||||
// Parse old times (ISO strings from the server) into the event's timezone
|
||||
// and extract HH:mm for comparison.
|
||||
const oldStartTime = moment.tz(oldStart, oldTimezone).format("HH:mm");
|
||||
const oldEndTime = moment.tz(oldEnd, oldTimezone).format("HH:mm");
|
||||
// Parse new times from the form. These may be either:
|
||||
// - local datetime strings like "2025-01-15T10:00" (from the form)
|
||||
// - ISO strings like "2025-01-15T10:00:00.000Z" (if pre-converted)
|
||||
// moment.tz with a format avoids ambiguous parsing in both cases.
|
||||
const newStartTime = moment.tz(newData.start, newTimezone).format("HH:mm");
|
||||
const newEndTime = moment.tz(newData.end, newTimezone).format("HH:mm");
|
||||
|
||||
const timeChanged =
|
||||
formStartTime !== oldStartTime || formEndTime !== oldEndTime;
|
||||
oldStartTime !== newStartTime || oldEndTime !== newEndTime;
|
||||
|
||||
const repetitionRulesChanged =
|
||||
JSON.stringify(oldRepetition) !== JSON.stringify(newRepetition) ||
|
||||
JSON.stringify(normalizeRepetition(oldEvent.repetition)) !==
|
||||
JSON.stringify(normalizeRepetition(newData.repetition)) ||
|
||||
timezoneChanged ||
|
||||
oldEvent.allday !== newData.allday ||
|
||||
timeChanged;
|
||||
|
||||
Reference in New Issue
Block a user