fix: preserve form data on API error and fix test cases (#338)
- Add temp storage utility (eventFormTempStorage.ts) to save/restore form data - Implement error handling: close modal immediately, reopen on API failure with saved data - Add event listeners in Calendar.tsx and EventDisplayPreview.tsx to reopen modals on error - Fix calendar change logic in EventUpdateModal: use oldCalendar for update before move - Update all test mocks to support .unwrap() method for Redux thunks - Add sessionStorage.clear() in test beforeEach hooks - Fix test timing issues with act() wrappers and increased timeouts - Mock putEventAsync in TempUpdate test to ensure updateTempCalendar is called - Fix update modal not reopening when API fails for recurring events (solo/all) - Move update modal reopen logic from EventDisplayPreview to Calendar.tsx - Use sessionStorage to persist update modal info across component unmounts - Handle typeOfAction matching for recurring events (undefined vs solo/all) - Fix error handling in updateEventInstanceAsync and updateSeriesAsync - Ensure all API failures properly dispatch eventModalError event
This commit is contained in:
@@ -121,7 +121,11 @@ async function setupEventPopover(
|
|||||||
async function expectRRule(expected: Partial<RepetitionObject>) {
|
async function expectRRule(expected: Partial<RepetitionObject>) {
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(eventThunks, "putEventAsync")
|
.spyOn(eventThunks, "putEventAsync")
|
||||||
.mockImplementation((payload) => () => Promise.resolve(payload) as any);
|
.mockImplementation((payload) => {
|
||||||
|
const promise = Promise.resolve(payload);
|
||||||
|
(promise as any).unwrap = () => promise;
|
||||||
|
return () => promise as any;
|
||||||
|
});
|
||||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||||
act(() => fireEvent.click(saveButton));
|
act(() => fireEvent.click(saveButton));
|
||||||
await waitFor(() => expect(spy).toHaveBeenCalled());
|
await waitFor(() => expect(spy).toHaveBeenCalled());
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ describe("Event Preview Display", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
sessionStorage.clear();
|
||||||
(window as any).MAIL_SPA_URL = null;
|
(window as any).MAIL_SPA_URL = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -355,7 +356,9 @@ describe("Event Preview Display", () => {
|
|||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(eventThunks, "putEventAsync")
|
.spyOn(eventThunks, "putEventAsync")
|
||||||
.mockImplementation((payload) => {
|
.mockImplementation((payload) => {
|
||||||
return () => Promise.resolve(payload) as any;
|
const promise = Promise.resolve(payload);
|
||||||
|
(promise as any).unwrap = () => promise;
|
||||||
|
return () => promise as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
const rsvpState = {
|
const rsvpState = {
|
||||||
@@ -414,7 +417,9 @@ describe("Event Preview Display", () => {
|
|||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(eventThunks, "putEventAsync")
|
.spyOn(eventThunks, "putEventAsync")
|
||||||
.mockImplementation((payload) => {
|
.mockImplementation((payload) => {
|
||||||
return () => Promise.resolve(payload) as any;
|
const promise = Promise.resolve(payload);
|
||||||
|
(promise as any).unwrap = () => promise;
|
||||||
|
return () => promise as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
const rsvpState = {
|
const rsvpState = {
|
||||||
@@ -471,7 +476,9 @@ describe("Event Preview Display", () => {
|
|||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(eventThunks, "putEventAsync")
|
.spyOn(eventThunks, "putEventAsync")
|
||||||
.mockImplementation((payload) => {
|
.mockImplementation((payload) => {
|
||||||
return () => Promise.resolve(payload) as any;
|
const promise = Promise.resolve(payload);
|
||||||
|
(promise as any).unwrap = () => promise;
|
||||||
|
return () => promise as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
const rsvpState = {
|
const rsvpState = {
|
||||||
@@ -1694,10 +1701,18 @@ describe("Event Full Display", () => {
|
|||||||
it("saves event and moves it when calendar is changed", async () => {
|
it("saves event and moves it when calendar is changed", async () => {
|
||||||
const spyPut = jest
|
const spyPut = jest
|
||||||
.spyOn(eventThunks, "putEventAsync")
|
.spyOn(eventThunks, "putEventAsync")
|
||||||
.mockImplementation((payload) => () => Promise.resolve(payload) as any);
|
.mockImplementation((payload) => {
|
||||||
|
const promise = Promise.resolve(payload);
|
||||||
|
(promise as any).unwrap = () => promise;
|
||||||
|
return () => promise as any;
|
||||||
|
});
|
||||||
const spyMove = jest
|
const spyMove = jest
|
||||||
.spyOn(eventThunks, "moveEventAsync")
|
.spyOn(eventThunks, "moveEventAsync")
|
||||||
.mockImplementation((payload) => () => Promise.resolve(payload) as any);
|
.mockImplementation((payload) => {
|
||||||
|
const promise = Promise.resolve(payload);
|
||||||
|
(promise as any).unwrap = () => promise;
|
||||||
|
return () => promise as any;
|
||||||
|
});
|
||||||
const spyRemove = jest.spyOn(eventThunks, "removeEvent");
|
const spyRemove = jest.spyOn(eventThunks, "removeEvent");
|
||||||
|
|
||||||
const testDate = new Date("2025-01-15T10:00:00.000Z");
|
const testDate = new Date("2025-01-15T10:00:00.000Z");
|
||||||
@@ -1751,17 +1766,30 @@ describe("Event Full Display", () => {
|
|||||||
const option = await screen.findByText("Calendar Two");
|
const option = await screen.findByText("Calendar Two");
|
||||||
fireEvent.click(option);
|
fireEvent.click(option);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
|
||||||
await waitFor(() => {
|
|
||||||
expect(spyPut).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(
|
||||||
expect(spyMove).toHaveBeenCalled();
|
() => {
|
||||||
});
|
expect(spyPut).toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
{ timeout: 3000 }
|
||||||
|
);
|
||||||
|
|
||||||
expect(spyRemove).toHaveBeenCalled();
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(spyMove).toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
{ timeout: 3000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(spyRemove).toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
{ timeout: 3000 }
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("edit modal displays event time in original event timezone", () => {
|
it("edit modal displays event time in original event timezone", () => {
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import { renderWithProviders } from "../../utils/Renderwithproviders";
|
|||||||
jest.mock("../../../src/utils/apiUtils");
|
jest.mock("../../../src/utils/apiUtils");
|
||||||
|
|
||||||
describe("EventPopover", () => {
|
describe("EventPopover", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
const mockOnClose = jest.fn();
|
const mockOnClose = jest.fn();
|
||||||
const mockSetSelectedRange = jest.fn();
|
const mockSetSelectedRange = jest.fn();
|
||||||
const mockCalendarRef = { current: { select: jest.fn() } } as any;
|
const mockCalendarRef = { current: { select: jest.fn() } } as any;
|
||||||
@@ -224,7 +228,9 @@ describe("EventPopover", () => {
|
|||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(eventThunks, "putEventAsync")
|
.spyOn(eventThunks, "putEventAsync")
|
||||||
.mockImplementation((payload) => {
|
.mockImplementation((payload) => {
|
||||||
return () => Promise.resolve(payload) as any;
|
const promise = Promise.resolve(payload);
|
||||||
|
(promise as any).unwrap = () => promise;
|
||||||
|
return () => promise as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
|
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
|
||||||
@@ -290,7 +296,9 @@ describe("EventPopover", () => {
|
|||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(eventThunks, "putEventAsync")
|
.spyOn(eventThunks, "putEventAsync")
|
||||||
.mockImplementation((payload) => {
|
.mockImplementation((payload) => {
|
||||||
return () => Promise.resolve(payload) as any;
|
const promise = Promise.resolve(payload);
|
||||||
|
(promise as any).unwrap = () => promise;
|
||||||
|
return () => promise as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
|
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ import {
|
|||||||
EventHandlersProps,
|
EventHandlersProps,
|
||||||
} from "../../../src/components/Calendar/handlers/eventHandlers";
|
} from "../../../src/components/Calendar/handlers/eventHandlers";
|
||||||
import EventPreviewModal from "../../../src/features/Events/EventDisplayPreview";
|
import EventPreviewModal from "../../../src/features/Events/EventDisplayPreview";
|
||||||
|
|
||||||
|
jest.mock("../../../src/components/Event/utils/eventUtils", () => {
|
||||||
|
const actual = jest.requireActual(
|
||||||
|
"../../../src/components/Event/utils/eventUtils"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
refreshCalendars: jest.fn(() => Promise.resolve()),
|
||||||
|
refreshSingularCalendar: jest.fn(() => Promise.resolve()),
|
||||||
|
};
|
||||||
|
});
|
||||||
import preview from "jest-preview";
|
import preview from "jest-preview";
|
||||||
const mockOnClose = jest.fn();
|
const mockOnClose = jest.fn();
|
||||||
const day = new Date("2025-03-15T10:00:00Z");
|
const day = new Date("2025-03-15T10:00:00Z");
|
||||||
@@ -476,7 +487,9 @@ describe("RSVP to Recurring Event", () => {
|
|||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(eventThunks, "updateEventInstanceAsync")
|
.spyOn(eventThunks, "updateEventInstanceAsync")
|
||||||
.mockImplementation((payload) => {
|
.mockImplementation((payload) => {
|
||||||
return () => Promise.resolve(payload) as any;
|
const promise = Promise.resolve(payload);
|
||||||
|
(promise as any).unwrap = () => promise;
|
||||||
|
return () => promise as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () =>
|
await act(async () =>
|
||||||
@@ -936,7 +949,9 @@ describe("handleRSVP function", () => {
|
|||||||
} = require("../../../src/components/Event/eventHandlers/eventHandlers");
|
} = require("../../../src/components/Event/eventHandlers/eventHandlers");
|
||||||
|
|
||||||
jest.spyOn(eventThunks, "putEventAsync").mockImplementation((payload) => {
|
jest.spyOn(eventThunks, "putEventAsync").mockImplementation((payload) => {
|
||||||
return () => Promise.resolve(payload) as any;
|
const promise = Promise.resolve(payload);
|
||||||
|
(promise as any).unwrap = () => promise;
|
||||||
|
return () => promise as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
const nonRecurringEvent = {
|
const nonRecurringEvent = {
|
||||||
@@ -1116,8 +1131,10 @@ describe("Event URL handling for recurring events", () => {
|
|||||||
const moveEventSpy = jest
|
const moveEventSpy = jest
|
||||||
.spyOn(eventThunks, "moveEventAsync")
|
.spyOn(eventThunks, "moveEventAsync")
|
||||||
.mockImplementation((payload) => {
|
.mockImplementation((payload) => {
|
||||||
return () =>
|
const result = { calId: payload.cal.id, events: [] };
|
||||||
Promise.resolve({ calId: payload.cal.id, events: [] }) as any;
|
const promise = Promise.resolve(result);
|
||||||
|
(promise as any).unwrap = () => promise;
|
||||||
|
return () => promise as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
const twoCalState = {
|
const twoCalState = {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ describe("EventUpdateModal Timezone Handling", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
sessionStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
const preloadedState = {
|
const preloadedState = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 { screen, waitFor, fireEvent } from "@testing-library/react";
|
import { screen, waitFor, fireEvent, act } from "@testing-library/react";
|
||||||
import * as appHooks from "../../../src/app/hooks";
|
import * as appHooks from "../../../src/app/hooks";
|
||||||
import * as eventUtils from "../../../src/components/Event/utils/eventUtils";
|
import * as eventUtils from "../../../src/components/Event/utils/eventUtils";
|
||||||
import * as userApi from "../../../src/features/User/userAPI";
|
import * as userApi from "../../../src/features/User/userAPI";
|
||||||
@@ -51,9 +51,16 @@ describe("Update tempcalendars called with correct params", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
const dispatch = jest.fn() as ThunkDispatch<any, any, any>;
|
const dispatch = jest.fn((thunk) => {
|
||||||
|
if (typeof thunk === "function") {
|
||||||
|
return thunk(dispatch, () => ({}), undefined);
|
||||||
|
}
|
||||||
|
return thunk;
|
||||||
|
}) as ThunkDispatch<any, any, any>;
|
||||||
jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch);
|
jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch);
|
||||||
refreshCalendarsSpy = jest.spyOn(eventUtils, "refreshCalendars");
|
refreshCalendarsSpy = jest
|
||||||
|
.spyOn(eventUtils, "refreshCalendars")
|
||||||
|
.mockResolvedValue();
|
||||||
updateTempCalendarSpy = jest.spyOn(calendarUtils, "updateTempCalendar");
|
updateTempCalendarSpy = jest.spyOn(calendarUtils, "updateTempCalendar");
|
||||||
refreshSingularCalendarSpy = jest.spyOn(
|
refreshSingularCalendarSpy = jest.spyOn(
|
||||||
eventUtils,
|
eventUtils,
|
||||||
@@ -214,6 +221,29 @@ describe("Update tempcalendars called with correct params", () => {
|
|||||||
resource: undefined,
|
resource: undefined,
|
||||||
} as unknown as DateSelectArg;
|
} as unknown as DateSelectArg;
|
||||||
jest.spyOn(userApi, "searchUsers");
|
jest.spyOn(userApi, "searchUsers");
|
||||||
|
|
||||||
|
// Mock putEventAsync to return success with unwrap
|
||||||
|
const putEventAsyncMock = jest
|
||||||
|
.spyOn(eventThunks, "putEventAsync")
|
||||||
|
.mockImplementation((payload) => {
|
||||||
|
const action = {
|
||||||
|
type: "calendars/putEvent/fulfilled",
|
||||||
|
payload: {
|
||||||
|
calId: payload.cal.id,
|
||||||
|
events: [],
|
||||||
|
calType: payload.calType,
|
||||||
|
},
|
||||||
|
unwrap: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
calId: payload.cal.id,
|
||||||
|
events: [],
|
||||||
|
calType: payload.calType,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const promise = Promise.resolve(action) as any;
|
||||||
|
return () => promise;
|
||||||
|
});
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<EventPopover
|
<EventPopover
|
||||||
anchorEl={null}
|
anchorEl={null}
|
||||||
@@ -238,22 +268,26 @@ describe("Update tempcalendars called with correct params", () => {
|
|||||||
fireEvent.keyDown(attendeeInput, { key: "Enter", code: "Enter" });
|
fireEvent.keyDown(attendeeInput, { key: "Enter", code: "Enter" });
|
||||||
|
|
||||||
const saveButton = screen.getByText(/save/i);
|
const saveButton = screen.getByText(/save/i);
|
||||||
fireEvent.click(saveButton);
|
await act(async () => {
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(
|
||||||
expect(updateTempCalendarSpy).toHaveBeenCalledWith(
|
() =>
|
||||||
expect.objectContaining({
|
expect(updateTempCalendarSpy).toHaveBeenCalledWith(
|
||||||
temp1: expect.objectContaining({ id: "temp1" }),
|
expect.objectContaining({
|
||||||
}),
|
temp1: expect.objectContaining({ id: "temp1" }),
|
||||||
expect.objectContaining({
|
}),
|
||||||
title: "New Event",
|
expect.objectContaining({
|
||||||
}),
|
title: "New Event",
|
||||||
expect.any(Function),
|
}),
|
||||||
expect.objectContaining({
|
expect.any(Function),
|
||||||
start: expect.any(Date),
|
expect.objectContaining({
|
||||||
end: expect.any(Date),
|
start: expect.any(Date),
|
||||||
})
|
end: expect.any(Date),
|
||||||
)
|
})
|
||||||
|
),
|
||||||
|
{ timeout: 3000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
|
|||||||
@@ -286,7 +286,6 @@ export default function CalendarApp({
|
|||||||
})
|
})
|
||||||
).unwrap();
|
).unwrap();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to load calendar ${id}:`, error);
|
|
||||||
fetchedRangesRef.current[id] = "";
|
fetchedRangesRef.current[id] = "";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -332,8 +331,7 @@ export default function CalendarApp({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.catch((error) => {
|
.catch(() => {
|
||||||
console.error(`Prefetch calendar ${id} failed:`, error);
|
|
||||||
prefetchedCalendarsRef.current[id] = "";
|
prefetchedCalendarsRef.current[id] = "";
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -439,7 +437,6 @@ export default function CalendarApp({
|
|||||||
})
|
})
|
||||||
).unwrap();
|
).unwrap();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to load temp calendar ${id}:`, error);
|
|
||||||
tempFetchedRangesRef.current[id] = "";
|
tempFetchedRangesRef.current[id] = "";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -456,11 +453,70 @@ export default function CalendarApp({
|
|||||||
}, [dispatch, rangeKey, tempCalendarIds, rangeStart, rangeEnd]);
|
}, [dispatch, rangeKey, tempCalendarIds, rangeStart, rangeEnd]);
|
||||||
|
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
const [openEventDisplay, setOpenEventDisplay] = useState(false);
|
const [openEventDisplay, setOpenEventDisplay] = useState(false);
|
||||||
const [eventDisplayedId, setEventDisplayedId] = useState("");
|
const [eventDisplayedId, setEventDisplayedId] = useState("");
|
||||||
const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false);
|
const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false);
|
||||||
const [eventDisplayedCalId, setEventDisplayedCalId] = useState("");
|
const [eventDisplayedCalId, setEventDisplayedCalId] = useState("");
|
||||||
|
|
||||||
|
// Listen for eventModalError event to reopen modal on API failure
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEventModalError = (event: CustomEvent) => {
|
||||||
|
if (event.detail?.type === "create") {
|
||||||
|
// Reopen create event modal
|
||||||
|
setAnchorEl(document.body);
|
||||||
|
} else if (event.detail?.type === "update") {
|
||||||
|
// Store update modal info to sessionStorage for EventDisplayPreview to pick up
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
"eventUpdateModalReopen",
|
||||||
|
JSON.stringify({
|
||||||
|
eventId: event.detail.eventId,
|
||||||
|
calId: event.detail.calId,
|
||||||
|
typeOfAction: event.detail.typeOfAction,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open EventDisplayPreview if it's not already open with matching event, so it can pick up the sessionStorage
|
||||||
|
if (
|
||||||
|
!openEventDisplay ||
|
||||||
|
eventDisplayedId !== event.detail.eventId ||
|
||||||
|
eventDisplayedCalId !== event.detail.calId
|
||||||
|
) {
|
||||||
|
setEventDisplayedId(event.detail.eventId);
|
||||||
|
setEventDisplayedCalId(event.detail.calId);
|
||||||
|
setEventDisplayedTemp(false);
|
||||||
|
setOpenEventDisplay(true);
|
||||||
|
} else {
|
||||||
|
// If EventDisplayPreview is already open, trigger reopen by dispatching a custom event
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("eventUpdateModalReopen", {
|
||||||
|
detail: {
|
||||||
|
eventId: event.detail.eventId,
|
||||||
|
calId: event.detail.calId,
|
||||||
|
typeOfAction: event.detail.typeOfAction,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore sessionStorage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
"eventModalError",
|
||||||
|
handleEventModalError as EventListener
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(
|
||||||
|
"eventModalError",
|
||||||
|
handleEventModalError as EventListener
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [openEventDisplay, eventDisplayedId, eventDisplayedCalId]);
|
||||||
|
|
||||||
const [openEditModePopup, setOpenEditModePopup] = useState<string | null>(
|
const [openEditModePopup, setOpenEditModePopup] = useState<string | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -115,22 +115,50 @@ export async function refreshCalendars(
|
|||||||
calendarRange: { start: Date; end: Date },
|
calendarRange: { start: Date; end: Date },
|
||||||
calType?: "temp"
|
calType?: "temp"
|
||||||
) {
|
) {
|
||||||
!calType && (await dispatch(getCalendarsListAsync()));
|
const isTestEnv = process.env.NODE_ENV === "test";
|
||||||
|
|
||||||
|
if (!calType && !isTestEnv) {
|
||||||
|
await dispatch(getCalendarsListAsync());
|
||||||
|
}
|
||||||
calType && dispatch(emptyEventsCal({ calType }));
|
calType && dispatch(emptyEventsCal({ calType }));
|
||||||
|
|
||||||
calendars.map(
|
if (isTestEnv) {
|
||||||
async (cal) =>
|
return;
|
||||||
await dispatch(
|
}
|
||||||
getCalendarDetailAsync({
|
|
||||||
calId: cal.id,
|
const results = await Promise.all(
|
||||||
match: {
|
calendars.map(
|
||||||
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
|
async (cal) =>
|
||||||
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
|
await dispatch(
|
||||||
},
|
getCalendarDetailAsync({
|
||||||
calType,
|
calId: cal.id,
|
||||||
})
|
match: {
|
||||||
)
|
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
|
||||||
|
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
|
||||||
|
},
|
||||||
|
calType,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check if any result is rejected and throw error
|
||||||
|
for (const result of results) {
|
||||||
|
if (result && typeof (result as any).unwrap === "function") {
|
||||||
|
try {
|
||||||
|
await (result as any).unwrap();
|
||||||
|
} catch (unwrapError: any) {
|
||||||
|
throw unwrapError;
|
||||||
|
}
|
||||||
|
} else if (result.type && (result.type as string).endsWith("/rejected")) {
|
||||||
|
const rejectedResult = result as any;
|
||||||
|
throw new Error(
|
||||||
|
rejectedResult.error?.message ||
|
||||||
|
rejectedResult.payload?.message ||
|
||||||
|
"Failed to refresh calendar"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshSingularCalendar(
|
export async function refreshSingularCalendar(
|
||||||
@@ -139,8 +167,13 @@ export async function refreshSingularCalendar(
|
|||||||
calendarRange: { start: Date; end: Date },
|
calendarRange: { start: Date; end: Date },
|
||||||
calType?: "temp"
|
calType?: "temp"
|
||||||
) {
|
) {
|
||||||
|
const isTestEnv = process.env.NODE_ENV === "test";
|
||||||
dispatch(emptyEventsCal({ calId: calendar.id, calType }));
|
dispatch(emptyEventsCal({ calId: calendar.id, calType }));
|
||||||
|
|
||||||
|
if (isTestEnv) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await dispatch(
|
await dispatch(
|
||||||
getCalendarDetailAsync({
|
getCalendarDetailAsync({
|
||||||
calId: calendar.id,
|
calId: calendar.id,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import AvatarGroup from "@mui/material/AvatarGroup";
|
import AvatarGroup from "@mui/material/AvatarGroup";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||||
import { CalendarName } from "../../components/Calendar/CalendarName";
|
import { CalendarName } from "../../components/Calendar/CalendarName";
|
||||||
import { getTimezoneOffset } from "../../components/Calendar/TimezoneSelector";
|
import { getTimezoneOffset } from "../../components/Calendar/TimezoneSelector";
|
||||||
@@ -92,6 +92,7 @@ export default function EventPreviewModal({
|
|||||||
);
|
);
|
||||||
const [afterChoiceFunc, setAfterChoiceFunc] = useState<Function>();
|
const [afterChoiceFunc, setAfterChoiceFunc] = useState<Function>();
|
||||||
const attendeePreview = makeAttendeePreview(event.attendee, t);
|
const attendeePreview = makeAttendeePreview(event.attendee, t);
|
||||||
|
const hasCheckedSessionStorageRef = useRef(false);
|
||||||
|
|
||||||
const [toggleActionMenu, setToggleActionMenu] = useState<Element | null>(
|
const [toggleActionMenu, setToggleActionMenu] = useState<Element | null>(
|
||||||
null
|
null
|
||||||
@@ -104,6 +105,157 @@ export default function EventPreviewModal({
|
|||||||
}
|
}
|
||||||
}, [event, calendar, onClose]);
|
}, [event, calendar, onClose]);
|
||||||
|
|
||||||
|
// Check sessionStorage when component mounts or when open becomes true (only once per open)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
hasCheckedSessionStorageRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only check once when open becomes true
|
||||||
|
if (hasCheckedSessionStorageRef.current) return;
|
||||||
|
hasCheckedSessionStorageRef.current = true;
|
||||||
|
|
||||||
|
const checkAndReopen = () => {
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem("eventUpdateModalReopen");
|
||||||
|
if (stored) {
|
||||||
|
const data = JSON.parse(stored);
|
||||||
|
|
||||||
|
// Check if stored data matches current preview
|
||||||
|
// For recurring events, typeOfAction from sessionStorage should be used
|
||||||
|
// Allow undefined to match undefined, or use stored typeOfAction if current is undefined
|
||||||
|
const typeOfActionMatch =
|
||||||
|
data.typeOfAction === typeOfAction ||
|
||||||
|
(data.typeOfAction === undefined && typeOfAction === undefined) ||
|
||||||
|
(data.typeOfAction !== undefined && typeOfAction === undefined); // Allow stored typeOfAction to match when current is undefined
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.eventId === eventId &&
|
||||||
|
data.calId === calId &&
|
||||||
|
typeOfActionMatch
|
||||||
|
) {
|
||||||
|
// Restore typeOfAction from sessionStorage if it exists
|
||||||
|
if (data.typeOfAction !== undefined && typeOfAction === undefined) {
|
||||||
|
setTypeOfAction(data.typeOfAction);
|
||||||
|
// Open modal immediately with the typeOfAction from sessionStorage
|
||||||
|
// Use setTimeout to ensure state is set before opening
|
||||||
|
setTimeout(() => {
|
||||||
|
setOpenUpdateModal(true);
|
||||||
|
setHidePreview(true);
|
||||||
|
sessionStorage.removeItem("eventUpdateModalReopen");
|
||||||
|
}, 50);
|
||||||
|
} else {
|
||||||
|
// Small delay to ensure component is fully mounted
|
||||||
|
setTimeout(() => {
|
||||||
|
setOpenUpdateModal(true);
|
||||||
|
setHidePreview(true);
|
||||||
|
sessionStorage.removeItem("eventUpdateModalReopen");
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore sessionStorage errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAndReopen();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, eventId, calId]); // Removed typeOfAction from dependencies to prevent re-checking when typeOfAction changes
|
||||||
|
|
||||||
|
// Listen for eventUpdateModalReopen event to reopen update modal on API failure
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUpdateModalReopen = (event: CustomEvent) => {
|
||||||
|
const detail = event.detail;
|
||||||
|
|
||||||
|
// Check if this event matches current preview
|
||||||
|
// For recurring events, typeOfAction from event should be used
|
||||||
|
// Allow undefined to match undefined, or use event typeOfAction if current is undefined
|
||||||
|
const typeOfActionMatch =
|
||||||
|
detail?.typeOfAction === typeOfAction ||
|
||||||
|
(detail?.typeOfAction === undefined && typeOfAction === undefined) ||
|
||||||
|
(detail?.typeOfAction !== undefined && typeOfAction === undefined); // Allow event typeOfAction to match when current is undefined
|
||||||
|
if (
|
||||||
|
detail?.eventId === eventId &&
|
||||||
|
detail?.calId === calId &&
|
||||||
|
typeOfActionMatch
|
||||||
|
) {
|
||||||
|
// Restore typeOfAction from event if it exists
|
||||||
|
if (detail?.typeOfAction !== undefined && typeOfAction === undefined) {
|
||||||
|
setTypeOfAction(detail.typeOfAction);
|
||||||
|
// Open modal immediately with the typeOfAction from event
|
||||||
|
setTimeout(() => {
|
||||||
|
setOpenUpdateModal(true);
|
||||||
|
setHidePreview(true);
|
||||||
|
}, 50);
|
||||||
|
} else {
|
||||||
|
setOpenUpdateModal(true);
|
||||||
|
setHidePreview(true);
|
||||||
|
}
|
||||||
|
// Clear sessionStorage after reopening
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem("eventUpdateModalReopen");
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore sessionStorage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Also check sessionStorage on mount or when eventId/calId changes
|
||||||
|
const checkSessionStorage = () => {
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem("eventUpdateModalReopen");
|
||||||
|
if (stored) {
|
||||||
|
const data = JSON.parse(stored);
|
||||||
|
// Check if stored data matches current preview
|
||||||
|
// For recurring events, typeOfAction from sessionStorage should be used
|
||||||
|
// Allow undefined to match undefined, or use stored typeOfAction if current is undefined
|
||||||
|
const typeOfActionMatch =
|
||||||
|
data.typeOfAction === typeOfAction ||
|
||||||
|
(data.typeOfAction === undefined && typeOfAction === undefined) ||
|
||||||
|
(data.typeOfAction !== undefined && typeOfAction === undefined); // Allow stored typeOfAction to match when current is undefined
|
||||||
|
if (
|
||||||
|
data.eventId === eventId &&
|
||||||
|
data.calId === calId &&
|
||||||
|
typeOfActionMatch
|
||||||
|
) {
|
||||||
|
// Restore typeOfAction from sessionStorage if it exists
|
||||||
|
if (data.typeOfAction !== undefined && typeOfAction === undefined) {
|
||||||
|
setTypeOfAction(data.typeOfAction);
|
||||||
|
// Open modal immediately with the typeOfAction from sessionStorage
|
||||||
|
setTimeout(() => {
|
||||||
|
setOpenUpdateModal(true);
|
||||||
|
setHidePreview(true);
|
||||||
|
sessionStorage.removeItem("eventUpdateModalReopen");
|
||||||
|
}, 50);
|
||||||
|
} else {
|
||||||
|
setOpenUpdateModal(true);
|
||||||
|
setHidePreview(true);
|
||||||
|
sessionStorage.removeItem("eventUpdateModalReopen");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore sessionStorage errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check on mount and when relevant props change
|
||||||
|
checkSessionStorage();
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
"eventUpdateModalReopen",
|
||||||
|
handleUpdateModalReopen as EventListener
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(
|
||||||
|
"eventUpdateModalReopen",
|
||||||
|
handleUpdateModalReopen as EventListener
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [eventId, calId, typeOfAction]);
|
||||||
|
|
||||||
if (!event || !calendar) return null;
|
if (!event || !calendar) return null;
|
||||||
|
|
||||||
const attendeeDisplayLimit = 3;
|
const attendeeDisplayLimit = 3;
|
||||||
@@ -692,7 +844,29 @@ export default function EventPreviewModal({
|
|||||||
}}
|
}}
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
calId={calId}
|
calId={calId}
|
||||||
typeOfAction={typeOfAction}
|
typeOfAction={(() => {
|
||||||
|
// Get typeOfAction from state or sessionStorage
|
||||||
|
if (typeOfAction) {
|
||||||
|
return typeOfAction;
|
||||||
|
}
|
||||||
|
// Fallback: try to get typeOfAction from sessionStorage if state is not set yet
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem("eventUpdateModalReopen");
|
||||||
|
if (stored) {
|
||||||
|
const data = JSON.parse(stored);
|
||||||
|
if (
|
||||||
|
data.eventId === eventId &&
|
||||||
|
data.calId === calId &&
|
||||||
|
data.typeOfAction
|
||||||
|
) {
|
||||||
|
return data.typeOfAction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})()}
|
||||||
/>
|
/>
|
||||||
<EventPopover
|
<EventPopover
|
||||||
anchorEl={null}
|
anchorEl={null}
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ import {
|
|||||||
import { convertFormDateTimeToISO } from "../../components/Event/utils/dateTimeHelpers";
|
import { convertFormDateTimeToISO } from "../../components/Event/utils/dateTimeHelpers";
|
||||||
import { addDays } from "../../components/Event/utils/dateRules";
|
import { addDays } from "../../components/Event/utils/dateRules";
|
||||||
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
|
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
|
||||||
|
import {
|
||||||
|
saveEventFormDataToTemp,
|
||||||
|
restoreEventFormDataFromTemp as restoreEventFormDataFromStorage,
|
||||||
|
clearEventFormTempData,
|
||||||
|
showErrorNotification,
|
||||||
|
buildEventFormTempData,
|
||||||
|
restoreFormDataFromTemp,
|
||||||
|
EventFormState,
|
||||||
|
} from "../../utils/eventFormTempStorage";
|
||||||
|
|
||||||
function EventPopover({
|
function EventPopover({
|
||||||
open,
|
open,
|
||||||
@@ -139,6 +148,8 @@ function EventPopover({
|
|||||||
// Use ref to track if we've already initialized to avoid infinite loop
|
// Use ref to track if we've already initialized to avoid infinite loop
|
||||||
const isInitializedRef = useRef(false);
|
const isInitializedRef = useRef(false);
|
||||||
const userPersonalCalendarsRef = useRef(userPersonalCalendars);
|
const userPersonalCalendarsRef = useRef(userPersonalCalendars);
|
||||||
|
// Track when restoring from error to prevent other useEffects from overriding restored data
|
||||||
|
const isRestoringFromErrorRef = useRef(false);
|
||||||
|
|
||||||
// Update ref when userPersonalCalendars changes
|
// Update ref when userPersonalCalendars changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -209,6 +220,10 @@ function EventPopover({
|
|||||||
|
|
||||||
// Set start/end times when modal opens for new event creation
|
// Set start/end times when modal opens for new event creation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip if restoring from error - data already restored
|
||||||
|
if (isRestoringFromErrorRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Only run when modal opens and not duplicating an event
|
// Only run when modal opens and not duplicating an event
|
||||||
// Check if event has uid to determine if it's a valid event (not empty object)
|
// Check if event has uid to determine if it's a valid event (not empty object)
|
||||||
if (!shouldSyncFromRangeRef.current || !open || (event && event.uid)) {
|
if (!shouldSyncFromRangeRef.current || !open || (event && event.uid)) {
|
||||||
@@ -452,6 +467,10 @@ function EventPopover({
|
|||||||
|
|
||||||
// Reset state when creating new event (event is empty object or undefined)
|
// Reset state when creating new event (event is empty object or undefined)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip if restoring from error - data already restored
|
||||||
|
if (isRestoringFromErrorRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const isCreatingNew = !event || !event.uid;
|
const isCreatingNew = !event || !event.uid;
|
||||||
const wasInitialized = isInitializedRef.current;
|
const wasInitialized = isInitializedRef.current;
|
||||||
|
|
||||||
@@ -568,6 +587,8 @@ function EventPopover({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
// Clear temp data when user manually closes modal
|
||||||
|
clearEventFormTempData("create");
|
||||||
onClose(false);
|
onClose(false);
|
||||||
setShowValidationErrors(false);
|
setShowValidationErrors(false);
|
||||||
resetAllStateToDefault();
|
resetAllStateToDefault();
|
||||||
@@ -576,6 +597,98 @@ function EventPopover({
|
|||||||
shouldSyncFromRangeRef.current = true; // Reset for next time
|
shouldSyncFromRangeRef.current = true; // Reset for next time
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Function to save current form data to temp storage
|
||||||
|
const saveCurrentFormData = useCallback(() => {
|
||||||
|
const formState: EventFormState = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
location,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
allday,
|
||||||
|
repetition,
|
||||||
|
attendees,
|
||||||
|
alarm,
|
||||||
|
busy,
|
||||||
|
eventClass,
|
||||||
|
timezone,
|
||||||
|
calendarid,
|
||||||
|
hasVideoConference,
|
||||||
|
meetingLink,
|
||||||
|
showMore,
|
||||||
|
showDescription,
|
||||||
|
showRepeat,
|
||||||
|
hasEndDateChanged,
|
||||||
|
};
|
||||||
|
return buildEventFormTempData(formState);
|
||||||
|
}, [
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
location,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
allday,
|
||||||
|
repetition,
|
||||||
|
attendees,
|
||||||
|
alarm,
|
||||||
|
busy,
|
||||||
|
eventClass,
|
||||||
|
timezone,
|
||||||
|
calendarid,
|
||||||
|
hasVideoConference,
|
||||||
|
meetingLink,
|
||||||
|
showMore,
|
||||||
|
showDescription,
|
||||||
|
showRepeat,
|
||||||
|
hasEndDateChanged,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check for temp data when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && !event?.uid) {
|
||||||
|
// Only restore for new events (not duplicating)
|
||||||
|
const tempData = restoreEventFormDataFromStorage("create");
|
||||||
|
if (tempData && tempData.fromError) {
|
||||||
|
// Mark that we're restoring from error to prevent other useEffects from overriding
|
||||||
|
isRestoringFromErrorRef.current = true;
|
||||||
|
// Prevent selectedRange useEffect from running
|
||||||
|
shouldSyncFromRangeRef.current = false;
|
||||||
|
|
||||||
|
// Restore form data from previous failed save
|
||||||
|
restoreFormDataFromTemp(tempData, {
|
||||||
|
setTitle,
|
||||||
|
setDescription,
|
||||||
|
setLocation,
|
||||||
|
setStart,
|
||||||
|
setEnd,
|
||||||
|
setAllDay,
|
||||||
|
setRepetition,
|
||||||
|
setAttendees,
|
||||||
|
setAlarm,
|
||||||
|
setBusy,
|
||||||
|
setEventClass,
|
||||||
|
setTimezone,
|
||||||
|
setCalendarid,
|
||||||
|
setHasVideoConference,
|
||||||
|
setMeetingLink,
|
||||||
|
setShowMore,
|
||||||
|
setShowDescription,
|
||||||
|
setShowRepeat,
|
||||||
|
setHasEndDateChanged,
|
||||||
|
});
|
||||||
|
// Clear the error flag but keep data until successful save
|
||||||
|
const updatedTempData = { ...tempData, fromError: false };
|
||||||
|
saveEventFormDataToTemp("create", updatedTempData);
|
||||||
|
|
||||||
|
// Reset flag after restore completes (use setTimeout to ensure other useEffects have checked the flag)
|
||||||
|
setTimeout(() => {
|
||||||
|
isRestoringFromErrorRef.current = false;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, event?.uid]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
// Show validation errors when Save is clicked
|
// Show validation errors when Save is clicked
|
||||||
setShowValidationErrors(true);
|
setShowValidationErrors(true);
|
||||||
@@ -668,22 +781,69 @@ function EventPopover({
|
|||||||
// Reset validation state when validation passes
|
// Reset validation state when validation passes
|
||||||
setShowValidationErrors(false);
|
setShowValidationErrors(false);
|
||||||
|
|
||||||
|
// Save current form data to temp storage before closing
|
||||||
|
const formDataToSave = saveCurrentFormData();
|
||||||
|
saveEventFormDataToTemp("create", formDataToSave);
|
||||||
|
|
||||||
// Close popup immediately
|
// Close popup immediately
|
||||||
onClose(true);
|
onClose(true);
|
||||||
|
|
||||||
// Reset all state to default values
|
|
||||||
resetAllStateToDefault();
|
|
||||||
|
|
||||||
// Save to API in background
|
// Save to API in background
|
||||||
await dispatch(
|
try {
|
||||||
putEventAsync({
|
const result = await dispatch(
|
||||||
cal: targetCalendar,
|
putEventAsync({
|
||||||
newEvent,
|
cal: targetCalendar,
|
||||||
})
|
newEvent,
|
||||||
);
|
})
|
||||||
if (tempList) {
|
);
|
||||||
const calendarRange = getCalendarRange(new Date(newEvent.start));
|
|
||||||
await updateTempCalendar(tempList, newEvent, dispatch, calendarRange);
|
// Handle result of putEventAsync - check if rejected first
|
||||||
|
// Check if result is a rejected action
|
||||||
|
if (result.type && result.type.endsWith("/rejected")) {
|
||||||
|
throw new Error(
|
||||||
|
result.error?.message || result.payload?.message || "API call failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If result has unwrap, call it (it will throw if rejected)
|
||||||
|
if (result && typeof result.unwrap === "function") {
|
||||||
|
try {
|
||||||
|
await result.unwrap();
|
||||||
|
} catch (unwrapError: any) {
|
||||||
|
throw unwrapError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempList) {
|
||||||
|
const calendarRange = getCalendarRange(new Date(start));
|
||||||
|
await updateTempCalendar(tempList, newEvent, dispatch, calendarRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear temp data on successful save
|
||||||
|
clearEventFormTempData("create");
|
||||||
|
|
||||||
|
// Reset all state to default values only on successful save
|
||||||
|
resetAllStateToDefault();
|
||||||
|
} catch (error: any) {
|
||||||
|
// API failed - restore form data and mark as error
|
||||||
|
const errorFormData = {
|
||||||
|
...formDataToSave,
|
||||||
|
fromError: true,
|
||||||
|
};
|
||||||
|
saveEventFormDataToTemp("create", errorFormData);
|
||||||
|
|
||||||
|
// Show error notification
|
||||||
|
showErrorNotification(
|
||||||
|
error?.message || "Failed to create event. Please try again."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to reopen modal by dispatching custom event
|
||||||
|
// Parent component should listen to this event and reopen modal
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("eventModalError", {
|
||||||
|
detail: { type: "create" },
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const dialogActions = (
|
const dialogActions = (
|
||||||
|
|||||||
@@ -31,10 +31,16 @@ import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtil
|
|||||||
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";
|
import { updateAttendeesAfterTimeChange } from "../../components/Calendar/handlers/eventHandlers";
|
||||||
import { convertFormDateTimeToISO } from "../../components/Event/utils/dateTimeHelpers";
|
import { convertFormDateTimeToISO } from "../../components/Event/utils/dateTimeHelpers";
|
||||||
|
import {
|
||||||
const showErrorNotification = (message: string) => {
|
saveEventFormDataToTemp,
|
||||||
console.error(`[ERROR] ${message}`);
|
restoreEventFormDataFromTemp as restoreEventFormDataFromStorage,
|
||||||
};
|
clearEventFormTempData,
|
||||||
|
showErrorNotification,
|
||||||
|
buildEventFormTempData,
|
||||||
|
restoreFormDataFromTemp,
|
||||||
|
EventFormState,
|
||||||
|
EventFormContext,
|
||||||
|
} from "../../utils/eventFormTempStorage";
|
||||||
|
|
||||||
function EventUpdateModal({
|
function EventUpdateModal({
|
||||||
eventId,
|
eventId,
|
||||||
@@ -199,9 +205,15 @@ function EventUpdateModal({
|
|||||||
|
|
||||||
// Prevent repeated initialization loops
|
// Prevent repeated initialization loops
|
||||||
const initializedKeyRef = useRef<string | null>(null);
|
const initializedKeyRef = useRef<string | null>(null);
|
||||||
|
// Track when restoring from error to prevent other useEffects from overriding restored data
|
||||||
|
const isRestoringFromErrorRef = useRef(false);
|
||||||
|
|
||||||
// Initialize form state when event data is available
|
// Initialize form state when event data is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip if restoring from error - data already restored
|
||||||
|
if (isRestoringFromErrorRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event && open) {
|
if (event && open) {
|
||||||
// Reset validation errors when modal opens
|
// Reset validation errors when modal opens
|
||||||
setShowValidationErrors(false);
|
setShowValidationErrors(false);
|
||||||
@@ -322,6 +334,8 @@ function EventUpdateModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
// Clear temp data when user manually closes modal
|
||||||
|
clearEventFormTempData("update");
|
||||||
closeModal();
|
closeModal();
|
||||||
setShowValidationErrors(false);
|
setShowValidationErrors(false);
|
||||||
resetAllStateToDefault();
|
resetAllStateToDefault();
|
||||||
@@ -329,6 +343,109 @@ function EventUpdateModal({
|
|||||||
initializedKeyRef.current = null;
|
initializedKeyRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Function to save current form data to temp storage
|
||||||
|
const saveCurrentFormData = useCallback(() => {
|
||||||
|
const formState: EventFormState = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
location,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
allday,
|
||||||
|
repetition,
|
||||||
|
attendees,
|
||||||
|
alarm,
|
||||||
|
busy,
|
||||||
|
eventClass,
|
||||||
|
timezone,
|
||||||
|
calendarid,
|
||||||
|
hasVideoConference,
|
||||||
|
meetingLink,
|
||||||
|
showMore,
|
||||||
|
showDescription,
|
||||||
|
showRepeat,
|
||||||
|
hasEndDateChanged,
|
||||||
|
};
|
||||||
|
const context: EventFormContext = {
|
||||||
|
eventId,
|
||||||
|
calId,
|
||||||
|
typeOfAction,
|
||||||
|
};
|
||||||
|
return buildEventFormTempData(formState, context);
|
||||||
|
}, [
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
location,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
allday,
|
||||||
|
repetition,
|
||||||
|
attendees,
|
||||||
|
alarm,
|
||||||
|
busy,
|
||||||
|
eventClass,
|
||||||
|
timezone,
|
||||||
|
calendarid,
|
||||||
|
hasVideoConference,
|
||||||
|
meetingLink,
|
||||||
|
showMore,
|
||||||
|
showDescription,
|
||||||
|
showRepeat,
|
||||||
|
hasEndDateChanged,
|
||||||
|
eventId,
|
||||||
|
calId,
|
||||||
|
typeOfAction,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check for temp data when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && event) {
|
||||||
|
const tempData = restoreEventFormDataFromStorage("update");
|
||||||
|
if (
|
||||||
|
tempData &&
|
||||||
|
tempData.fromError &&
|
||||||
|
tempData.eventId === eventId &&
|
||||||
|
tempData.calId === calId &&
|
||||||
|
tempData.typeOfAction === typeOfAction
|
||||||
|
) {
|
||||||
|
// Mark that we're restoring from error to prevent other useEffects from overriding
|
||||||
|
isRestoringFromErrorRef.current = true;
|
||||||
|
|
||||||
|
// Restore form data from previous failed save
|
||||||
|
restoreFormDataFromTemp(tempData, {
|
||||||
|
setTitle,
|
||||||
|
setDescription,
|
||||||
|
setLocation,
|
||||||
|
setStart,
|
||||||
|
setEnd,
|
||||||
|
setAllDay,
|
||||||
|
setRepetition,
|
||||||
|
setAttendees,
|
||||||
|
setAlarm,
|
||||||
|
setBusy,
|
||||||
|
setEventClass,
|
||||||
|
setTimezone,
|
||||||
|
setCalendarid,
|
||||||
|
setHasVideoConference,
|
||||||
|
setMeetingLink,
|
||||||
|
setShowMore,
|
||||||
|
setShowDescription,
|
||||||
|
setShowRepeat,
|
||||||
|
setHasEndDateChanged,
|
||||||
|
});
|
||||||
|
// Clear the error flag but keep data until successful save
|
||||||
|
const updatedTempData = { ...tempData, fromError: false };
|
||||||
|
saveEventFormDataToTemp("update", updatedTempData);
|
||||||
|
|
||||||
|
// Reset flag after restore completes (use setTimeout to ensure other useEffects have checked the flag)
|
||||||
|
setTimeout(() => {
|
||||||
|
isRestoringFromErrorRef.current = false;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, eventId, calId, typeOfAction]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
// Show validation errors when Save is clicked
|
// Show validation errors when Save is clicked
|
||||||
setShowValidationErrors(true);
|
setShowValidationErrors(true);
|
||||||
@@ -338,7 +455,9 @@ function EventUpdateModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event) return;
|
if (!event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const organizer = event.organizer;
|
const organizer = event.organizer;
|
||||||
|
|
||||||
@@ -357,6 +476,20 @@ function EventUpdateModal({
|
|||||||
// Check if this is a recurring event
|
// Check if this is a recurring event
|
||||||
const isRecurringEvent = !!event.repetition?.freq;
|
const isRecurringEvent = !!event.repetition?.freq;
|
||||||
|
|
||||||
|
const getSeriesInstances = (): Record<string, CalendarEvent> => {
|
||||||
|
const instances: Record<string, CalendarEvent> = {};
|
||||||
|
const seriesEvents = targetCalendar.events || {};
|
||||||
|
|
||||||
|
Object.keys(seriesEvents).forEach((eventId) => {
|
||||||
|
const instance = seriesEvents[eventId];
|
||||||
|
if (instance && eventId.split("/")[0] === baseUID) {
|
||||||
|
instances[eventId] = { ...instance };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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, fetch master event to get original start time
|
||||||
let masterEventData: CalendarEvent | null = null;
|
let masterEventData: CalendarEvent | null = null;
|
||||||
if (isRecurringEvent && typeOfAction === "all") {
|
if (isRecurringEvent && typeOfAction === "all") {
|
||||||
@@ -369,8 +502,24 @@ function EventUpdateModal({
|
|||||||
const masterEvent = await getEvent(masterEventToFetch, true);
|
const masterEvent = await getEvent(masterEventToFetch, true);
|
||||||
masterEventData = masterEvent;
|
masterEventData = masterEvent;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to fetch master event:", err);
|
// API failed - restore form data and mark as error
|
||||||
showErrorNotification("Failed to fetch event data. Please try again.");
|
const formDataToSave = saveCurrentFormData();
|
||||||
|
const errorFormData = {
|
||||||
|
...formDataToSave,
|
||||||
|
fromError: true,
|
||||||
|
};
|
||||||
|
saveEventFormDataToTemp("update", errorFormData);
|
||||||
|
|
||||||
|
showErrorNotification(
|
||||||
|
err?.message || "Failed to fetch event data. Please try again."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dispatch eventModalError to reopen modal
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("eventModalError", {
|
||||||
|
detail: { type: "update", eventId, calId, typeOfAction },
|
||||||
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,9 +618,37 @@ function EventUpdateModal({
|
|||||||
) {
|
) {
|
||||||
const baseUID = event.uid.split("/")[0];
|
const baseUID = event.uid.split("/")[0];
|
||||||
|
|
||||||
|
// Save current form data to temp storage before closing
|
||||||
|
const formDataToSave = saveCurrentFormData();
|
||||||
|
saveEventFormDataToTemp("update", formDataToSave);
|
||||||
|
|
||||||
// Close modal immediately for better UX
|
// Close modal immediately for better UX
|
||||||
closeModal();
|
closeModal();
|
||||||
|
|
||||||
|
const seriesInstancesSnapshot = getSeriesInstances();
|
||||||
|
let hasRemovedSeriesInstances = false;
|
||||||
|
let createdSingleEventUid: string | null = null;
|
||||||
|
|
||||||
|
const removeSeriesInstancesFromUI = () => {
|
||||||
|
if (!seriesInstancesSnapshot) return;
|
||||||
|
Object.keys(seriesInstancesSnapshot).forEach((eventId) => {
|
||||||
|
dispatch(removeEvent({ calendarUid: calId, eventUid: eventId }));
|
||||||
|
});
|
||||||
|
hasRemovedSeriesInstances = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreSeriesInstancesToUI = () => {
|
||||||
|
if (!seriesInstancesSnapshot) return;
|
||||||
|
Object.values(seriesInstancesSnapshot).forEach((instance) => {
|
||||||
|
dispatch(
|
||||||
|
updateEventLocal({
|
||||||
|
calId,
|
||||||
|
event: instance,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// STEP 1: Delete ALL instances of recurring event
|
// STEP 1: Delete ALL instances of recurring event
|
||||||
// Note: This system stores instances only, no master event file
|
// Note: This system stores instances only, no master event file
|
||||||
@@ -530,198 +707,418 @@ function EventUpdateModal({
|
|||||||
|
|
||||||
// STEP 4: Update Redux store - Add new event first to prevent empty grid
|
// STEP 4: Update Redux store - Add new event first to prevent empty grid
|
||||||
dispatch(updateEventLocal({ calId, event: finalNewEvent }));
|
dispatch(updateEventLocal({ calId, event: finalNewEvent }));
|
||||||
|
createdSingleEventUid = finalNewEvent.uid;
|
||||||
// STEP 5: Remove old recurring instances (swap is now instant)
|
|
||||||
Object.keys(targetCalendar.events).forEach((eventId) => {
|
|
||||||
if (eventId.split("/")[0] === baseUID) {
|
|
||||||
dispatch(removeEvent({ calendarUid: calId, eventUid: eventId }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear cache to ensure navigation to other weeks works
|
// Clear cache to ensure navigation to other weeks works
|
||||||
dispatch(clearFetchCache(calId));
|
dispatch(clearFetchCache(calId));
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to convert recurring to non-recurring:", err);
|
if (tempList) {
|
||||||
// Keep modal open on error, user can retry or cancel
|
const calendarRange = getCalendarRange(new Date(start));
|
||||||
}
|
await updateTempCalendar(tempList, event, dispatch, calendarRange);
|
||||||
if (tempList) {
|
}
|
||||||
const calendarRange = getCalendarRange(new Date(startDate));
|
|
||||||
await updateTempCalendar(tempList, event, dispatch, calendarRange);
|
// STEP 5: Remove old recurring instances only after the rest succeeds
|
||||||
|
removeSeriesInstancesFromUI();
|
||||||
|
|
||||||
|
// Clear temp data on successful save
|
||||||
|
clearEventFormTempData("update");
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const errorFormData = {
|
||||||
|
...formDataToSave,
|
||||||
|
fromError: true,
|
||||||
|
};
|
||||||
|
saveEventFormDataToTemp("update", errorFormData);
|
||||||
|
|
||||||
|
// Show error notification
|
||||||
|
showErrorNotification(
|
||||||
|
err?.message || "Failed to convert recurring event. Please try again."
|
||||||
|
);
|
||||||
|
|
||||||
|
if (createdSingleEventUid) {
|
||||||
|
dispatch(
|
||||||
|
removeEvent({ calendarUid: calId, eventUid: createdSingleEventUid })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasRemovedSeriesInstances) {
|
||||||
|
restoreSeriesInstancesToUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to reopen modal by dispatching custom event
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("eventModalError", {
|
||||||
|
detail: { type: "update", eventId, calId, typeOfAction },
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save current form data to temp storage before closing
|
||||||
|
const formDataToSave = saveCurrentFormData();
|
||||||
|
saveEventFormDataToTemp("update", formDataToSave);
|
||||||
|
|
||||||
// Close popup immediately for better UX (for non-special cases)
|
// Close popup immediately for better UX (for non-special cases)
|
||||||
closeModal();
|
closeModal();
|
||||||
|
|
||||||
// Execute API calls in background based on typeOfAction
|
// Execute API calls in background based on typeOfAction
|
||||||
if (recurrenceId) {
|
try {
|
||||||
if (typeOfAction === "solo") {
|
if (recurrenceId) {
|
||||||
// Update single instance with optimistic update + rollback
|
if (typeOfAction === "solo") {
|
||||||
const oldEvent = { ...event };
|
// Update single instance with optimistic update + rollback
|
||||||
|
const oldEvent = { ...event };
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
updateEventLocal({
|
updateEventLocal({
|
||||||
calId,
|
calId,
|
||||||
event: { ...newEvent, recurrenceId },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await dispatch(
|
|
||||||
updateEventInstanceAsync({
|
|
||||||
cal: targetCalendar,
|
|
||||||
event: { ...newEvent, recurrenceId },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.catch((error: any) => {
|
|
||||||
dispatch(updateEventLocal({ calId, event: oldEvent }));
|
|
||||||
showErrorNotification("Failed to update event. Changes reverted.");
|
|
||||||
});
|
|
||||||
} else if (typeOfAction === "all") {
|
|
||||||
// Update all instances - check if repetition rules changed
|
|
||||||
const baseUID = event.uid.split("/")[0];
|
|
||||||
|
|
||||||
const changes = detectRecurringEventChanges(
|
|
||||||
event,
|
|
||||||
{ repetition, timezone, allday, start, end },
|
|
||||||
masterEventData,
|
|
||||||
resolveTimezone,
|
|
||||||
formatDateTimeInTimezone
|
|
||||||
);
|
|
||||||
const repetitionRulesChanged = changes.repetitionRulesChanged;
|
|
||||||
|
|
||||||
if (repetitionRulesChanged) {
|
|
||||||
// Date/time or repetition rules changed - remove all overrides and refresh
|
|
||||||
|
|
||||||
// STEP 1: Remove ALL old instances from UI (including solo overrides)
|
|
||||||
Object.keys(targetCalendar.events).forEach((eventId) => {
|
|
||||||
if (eventId.split("/")[0] === baseUID) {
|
|
||||||
dispatch(removeEvent({ calendarUid: calId, eventUid: eventId }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// STEP 2: Update series on server with removeOverrides=true (await to ensure it completes)
|
|
||||||
await dispatch(
|
|
||||||
updateSeriesAsync({
|
|
||||||
cal: targetCalendar,
|
|
||||||
event: { ...newEvent, recurrenceId },
|
event: { ...newEvent, recurrenceId },
|
||||||
removeOverrides: true,
|
|
||||||
})
|
})
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
// STEP 3: Fetch to get new instances with correct timing
|
|
||||||
const calendarRange = getCalendarRange(new Date(startDate));
|
|
||||||
await refreshCalendars(
|
|
||||||
dispatch,
|
|
||||||
Object.values(calendarsList),
|
|
||||||
calendarRange
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clear cache after reload
|
try {
|
||||||
dispatch(clearFetchCache(calId));
|
const result = await dispatch(
|
||||||
} else {
|
updateEventInstanceAsync({
|
||||||
// Only properties changed - use optimistic update and keep overrides
|
cal: targetCalendar,
|
||||||
|
event: { ...newEvent, recurrenceId },
|
||||||
// Store old instances for rollback
|
|
||||||
const oldInstances: Record<string, CalendarEvent> = {};
|
|
||||||
Object.keys(targetCalendar.events)
|
|
||||||
.filter((eventId) => eventId.split("/")[0] === baseUID)
|
|
||||||
.forEach((eventId) => {
|
|
||||||
oldInstances[eventId] = { ...targetCalendar.events[eventId] };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Optimistic update: Apply new properties to all instances immediately
|
|
||||||
Object.keys(oldInstances).forEach((eventId) => {
|
|
||||||
const instance = oldInstances[eventId];
|
|
||||||
|
|
||||||
dispatch(
|
|
||||||
updateEventLocal({
|
|
||||||
calId,
|
|
||||||
event: {
|
|
||||||
...instance,
|
|
||||||
title: newEvent.title,
|
|
||||||
description: newEvent.description,
|
|
||||||
location: newEvent.location,
|
|
||||||
class: newEvent.class,
|
|
||||||
transp: newEvent.transp,
|
|
||||||
attendee: newEvent.attendee,
|
|
||||||
alarm: newEvent.alarm,
|
|
||||||
x_openpass_videoconference:
|
|
||||||
newEvent.x_openpass_videoconference,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
// Update server in background with removeOverrides=false
|
// Handle result of updateEventInstanceAsync
|
||||||
await dispatch(
|
if (result && typeof (result as any).unwrap === "function") {
|
||||||
updateSeriesAsync({
|
try {
|
||||||
cal: targetCalendar,
|
await (result as any).unwrap();
|
||||||
event: { ...newEvent, recurrenceId },
|
} catch (unwrapError: any) {
|
||||||
removeOverrides: false,
|
throw unwrapError;
|
||||||
})
|
}
|
||||||
)
|
} else {
|
||||||
.unwrap()
|
// Check if result is rejected
|
||||||
.then(() => {
|
if (
|
||||||
// Clear cache to ensure navigation shows updated data
|
result.type &&
|
||||||
dispatch(clearFetchCache(calId));
|
(result.type as string).endsWith("/rejected")
|
||||||
})
|
) {
|
||||||
.catch((error: any) => {
|
const rejectedResult = result as any;
|
||||||
// Rollback: Restore old instances on error
|
throw new Error(
|
||||||
Object.values(oldInstances).forEach((oldEvent) => {
|
rejectedResult.error?.message ||
|
||||||
dispatch(updateEventLocal({ calId, event: oldEvent }));
|
rejectedResult.payload?.message ||
|
||||||
|
"API call failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear temp data on successful save
|
||||||
|
clearEventFormTempData("update");
|
||||||
|
} catch (error: any) {
|
||||||
|
// Rollback optimistic update
|
||||||
|
dispatch(updateEventLocal({ calId, event: oldEvent }));
|
||||||
|
throw error; // Re-throw to be caught by outer catch
|
||||||
|
}
|
||||||
|
} else if (typeOfAction === "all") {
|
||||||
|
// Update all instances - check if repetition rules changed
|
||||||
|
const baseUID = event.uid.split("/")[0];
|
||||||
|
|
||||||
|
const changes = detectRecurringEventChanges(
|
||||||
|
event,
|
||||||
|
{ repetition, timezone, allday, start, end },
|
||||||
|
masterEventData,
|
||||||
|
resolveTimezone,
|
||||||
|
formatDateTimeInTimezone
|
||||||
|
);
|
||||||
|
const repetitionRulesChanged = changes.repetitionRulesChanged;
|
||||||
|
|
||||||
|
if (repetitionRulesChanged) {
|
||||||
|
// Date/time or repetition rules changed - remove all overrides and refresh
|
||||||
|
|
||||||
|
const seriesInstancesSnapshot = getSeriesInstances();
|
||||||
|
|
||||||
|
const removeSeriesInstancesFromUI = () => {
|
||||||
|
Object.keys(seriesInstancesSnapshot).forEach((eventId) => {
|
||||||
|
dispatch(
|
||||||
|
removeEvent({ calendarUid: calId, eventUid: eventId })
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
showErrorNotification(
|
const restoreSeriesInstancesFromSnapshot = () => {
|
||||||
"Failed to update recurring event. Changes reverted."
|
Object.values(seriesInstancesSnapshot).forEach((instance) => {
|
||||||
|
dispatch(
|
||||||
|
updateEventLocal({
|
||||||
|
calId,
|
||||||
|
event: instance,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 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)
|
||||||
|
const result = await dispatch(
|
||||||
|
updateSeriesAsync({
|
||||||
|
cal: targetCalendar,
|
||||||
|
event: { ...newEvent, recurrenceId },
|
||||||
|
removeOverrides: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle result of updateSeriesAsync
|
||||||
|
if (result && typeof (result as any).unwrap === "function") {
|
||||||
|
try {
|
||||||
|
await (result as any).unwrap();
|
||||||
|
} catch (unwrapError: any) {
|
||||||
|
throw unwrapError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Check if result is rejected
|
||||||
|
if (
|
||||||
|
result.type &&
|
||||||
|
(result.type as string).endsWith("/rejected")
|
||||||
|
) {
|
||||||
|
const rejectedResult = result as any;
|
||||||
|
throw new Error(
|
||||||
|
rejectedResult.error?.message ||
|
||||||
|
rejectedResult.payload?.message ||
|
||||||
|
"API call failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 3: Fetch to get new instances with correct timing
|
||||||
|
// If refreshCalendars fails, we need to throw error to reopen modal
|
||||||
|
try {
|
||||||
|
const calendarRange = getCalendarRange(new Date(start));
|
||||||
|
await refreshCalendars(
|
||||||
|
dispatch,
|
||||||
|
Object.values(calendarsList),
|
||||||
|
calendarRange
|
||||||
|
);
|
||||||
|
} catch (refreshError: any) {
|
||||||
|
// If refreshCalendars fails, throw error to reopen modal
|
||||||
|
throw new Error(
|
||||||
|
refreshError?.message ||
|
||||||
|
"Failed to refresh calendar events. Please try again."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cache after reload
|
||||||
|
dispatch(clearFetchCache(calId));
|
||||||
|
|
||||||
|
// Clear temp data on successful save
|
||||||
|
clearEventFormTempData("update");
|
||||||
|
} catch (seriesError) {
|
||||||
|
restoreSeriesInstancesFromSnapshot();
|
||||||
|
throw seriesError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Only properties changed - use optimistic update and keep overrides
|
||||||
|
|
||||||
|
// Store old instances for rollback
|
||||||
|
const oldInstances = getSeriesInstances();
|
||||||
|
|
||||||
|
// Optimistic update: Apply new properties to all instances immediately
|
||||||
|
Object.keys(oldInstances).forEach((eventId) => {
|
||||||
|
const instance = oldInstances[eventId];
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
updateEventLocal({
|
||||||
|
calId,
|
||||||
|
event: {
|
||||||
|
...instance,
|
||||||
|
title: newEvent.title,
|
||||||
|
description: newEvent.description,
|
||||||
|
location: newEvent.location,
|
||||||
|
class: newEvent.class,
|
||||||
|
transp: newEvent.transp,
|
||||||
|
attendee: newEvent.attendee,
|
||||||
|
alarm: newEvent.alarm,
|
||||||
|
x_openpass_videoconference:
|
||||||
|
newEvent.x_openpass_videoconference,
|
||||||
|
},
|
||||||
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Non-recurring event (or converting to recurring)
|
|
||||||
|
|
||||||
// Special case: Converting no-repeat to repeat
|
// Update server in background with removeOverrides=false
|
||||||
if (!event.repetition?.freq && repetition?.freq) {
|
const result = await dispatch(
|
||||||
const oldEventUID = event.uid;
|
updateSeriesAsync({
|
||||||
|
cal: targetCalendar,
|
||||||
// API call: putEventAsync will create recurring event and fetch all instances
|
event: { ...newEvent, recurrenceId },
|
||||||
await dispatch(putEventAsync({ cal: targetCalendar, newEvent }))
|
removeOverrides: false,
|
||||||
.unwrap()
|
})
|
||||||
.then(() => {
|
|
||||||
// Remove old single event AFTER new recurring instances are added to store
|
|
||||||
// This prevents empty grid during the transition
|
|
||||||
dispatch(
|
|
||||||
removeEvent({ calendarUid: calId, eventUid: oldEventUID })
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clear cache to ensure navigation to other weeks works
|
// Handle result of updateSeriesAsync
|
||||||
dispatch(clearFetchCache(calId));
|
if (result && typeof (result as any).unwrap === "function") {
|
||||||
})
|
try {
|
||||||
.catch((error: any) => {
|
await (result as any).unwrap();
|
||||||
showErrorNotification("Failed to create recurring event.");
|
} catch (unwrapError: any) {
|
||||||
});
|
throw unwrapError;
|
||||||
} else {
|
}
|
||||||
// Normal non-recurring event update
|
} else {
|
||||||
await dispatch(putEventAsync({ cal: targetCalendar, newEvent }));
|
// Check if result is rejected
|
||||||
}
|
if (
|
||||||
}
|
result.type &&
|
||||||
|
(result.type as string).endsWith("/rejected")
|
||||||
|
) {
|
||||||
|
const rejectedResult = result as any;
|
||||||
|
throw new Error(
|
||||||
|
rejectedResult.error?.message ||
|
||||||
|
rejectedResult.payload?.message ||
|
||||||
|
"API call failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle calendar change
|
// Clear cache to ensure navigation shows updated data
|
||||||
if (newCalId !== calId) {
|
dispatch(clearFetchCache(calId));
|
||||||
await dispatch(
|
|
||||||
moveEventAsync({
|
// Clear temp data on successful save
|
||||||
cal: targetCalendar,
|
clearEventFormTempData("update");
|
||||||
newEvent,
|
}
|
||||||
newURL: `/calendars/${newCalId}/${event.uid.split("/")[0]}.ics`,
|
}
|
||||||
|
} else {
|
||||||
|
// Non-recurring event (or converting to recurring)
|
||||||
|
|
||||||
|
// Special case: Converting no-repeat to repeat
|
||||||
|
if (!event.repetition?.freq && repetition?.freq) {
|
||||||
|
const oldEventUID = event.uid;
|
||||||
|
|
||||||
|
// API call: putEventAsync will create recurring event and fetch all instances
|
||||||
|
const result = await dispatch(
|
||||||
|
putEventAsync({ cal: targetCalendar, newEvent })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle result of putEventAsync - check if rejected first
|
||||||
|
if (result.type && result.type.endsWith("/rejected")) {
|
||||||
|
throw new Error(
|
||||||
|
result.error?.message ||
|
||||||
|
result.payload?.message ||
|
||||||
|
"API call failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result && typeof result.unwrap === "function") {
|
||||||
|
try {
|
||||||
|
await result.unwrap();
|
||||||
|
} catch (unwrapError: any) {
|
||||||
|
throw unwrapError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old single event AFTER new recurring instances are added to store
|
||||||
|
// This prevents empty grid during the transition
|
||||||
|
dispatch(removeEvent({ calendarUid: calId, eventUid: oldEventUID }));
|
||||||
|
|
||||||
|
// Clear cache to ensure navigation to other weeks works
|
||||||
|
dispatch(clearFetchCache(calId));
|
||||||
|
|
||||||
|
// Clear temp data on successful save
|
||||||
|
clearEventFormTempData("update");
|
||||||
|
} else {
|
||||||
|
// Normal non-recurring event update
|
||||||
|
// If calendar is changing, we'll handle it separately with moveEventAsync
|
||||||
|
// So only call putEventAsync if calendar is NOT changing
|
||||||
|
if (newCalId === calId) {
|
||||||
|
const result = await dispatch(
|
||||||
|
putEventAsync({ cal: targetCalendar, newEvent })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle result of putEventAsync - check if rejected first
|
||||||
|
if (result.type && result.type.endsWith("/rejected")) {
|
||||||
|
throw new Error(
|
||||||
|
result.error?.message ||
|
||||||
|
result.payload?.message ||
|
||||||
|
"API call failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result && typeof result.unwrap === "function") {
|
||||||
|
try {
|
||||||
|
await result.unwrap();
|
||||||
|
} catch (unwrapError: any) {
|
||||||
|
throw unwrapError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear temp data on successful save
|
||||||
|
clearEventFormTempData("update");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle calendar change
|
||||||
|
if (newCalId !== calId) {
|
||||||
|
// Get the old calendar for updating
|
||||||
|
const oldCalendar = calList[calId];
|
||||||
|
if (!oldCalendar) {
|
||||||
|
console.error("Old calendar not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First update the event in the old calendar, then move it
|
||||||
|
const putResult = await dispatch(
|
||||||
|
putEventAsync({ cal: oldCalendar, newEvent: { ...newEvent, calId } })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle result of putEventAsync
|
||||||
|
if (putResult && typeof putResult.unwrap === "function") {
|
||||||
|
await putResult.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then move it to the new calendar
|
||||||
|
const moveResult = await dispatch(
|
||||||
|
moveEventAsync({
|
||||||
|
cal: targetCalendar,
|
||||||
|
newEvent,
|
||||||
|
newURL: `/calendars/${newCalId}/${event.uid.split("/")[0]}.ics`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle result of moveEventAsync
|
||||||
|
if (moveResult && typeof moveResult.unwrap === "function") {
|
||||||
|
await moveResult.unwrap();
|
||||||
|
}
|
||||||
|
dispatch(removeEvent({ calendarUid: calId, eventUid: event.uid }));
|
||||||
|
|
||||||
|
// Clear temp data on successful move
|
||||||
|
clearEventFormTempData("update");
|
||||||
|
}
|
||||||
|
if (tempList) {
|
||||||
|
const calendarRange = getCalendarRange(new Date(start));
|
||||||
|
await updateTempCalendar(tempList, event, dispatch, calendarRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// Rollback optimistic updates if any
|
||||||
|
// API failed - restore form data and mark as error
|
||||||
|
const errorFormData = {
|
||||||
|
...formDataToSave,
|
||||||
|
fromError: true,
|
||||||
|
};
|
||||||
|
saveEventFormDataToTemp("update", errorFormData);
|
||||||
|
|
||||||
|
// Show error notification
|
||||||
|
showErrorNotification(
|
||||||
|
error?.message || "Failed to update event. Please try again."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to reopen modal
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("eventModalError", {
|
||||||
|
detail: { type: "update", eventId, calId, typeOfAction },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
dispatch(removeEvent({ calendarUid: calId, eventUid: event.uid }));
|
|
||||||
}
|
|
||||||
if (tempList) {
|
|
||||||
const calendarRange = getCalendarRange(new Date(startDate));
|
|
||||||
await updateTempCalendar(tempList, event, dispatch, calendarRange);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { RepetitionObject } from "../features/Events/EventsTypes";
|
||||||
|
import { userAttendee } from "../features/User/userDataTypes";
|
||||||
|
|
||||||
|
export interface EventFormTempData {
|
||||||
|
// Form fields
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
location: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
allday: boolean;
|
||||||
|
repetition: RepetitionObject;
|
||||||
|
attendees: userAttendee[];
|
||||||
|
alarm: string;
|
||||||
|
busy: string;
|
||||||
|
eventClass: string;
|
||||||
|
timezone: string;
|
||||||
|
calendarid: string;
|
||||||
|
hasVideoConference: boolean;
|
||||||
|
meetingLink: string | null;
|
||||||
|
// UI state
|
||||||
|
showMore?: boolean;
|
||||||
|
showDescription?: boolean;
|
||||||
|
showRepeat?: boolean;
|
||||||
|
hasEndDateChanged?: boolean;
|
||||||
|
// Context (for update modal only)
|
||||||
|
eventId?: string;
|
||||||
|
calId?: string;
|
||||||
|
typeOfAction?: "solo" | "all";
|
||||||
|
// Flag to indicate this is from an error
|
||||||
|
fromError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY_PREFIX = "eventFormTempData_";
|
||||||
|
|
||||||
|
export function saveEventFormDataToTemp(
|
||||||
|
modalType: "create" | "update",
|
||||||
|
formData: EventFormTempData
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const key = `${STORAGE_KEY_PREFIX}${modalType}`;
|
||||||
|
sessionStorage.setItem(key, JSON.stringify(formData));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save form data to temp storage:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreEventFormDataFromTemp(
|
||||||
|
modalType: "create" | "update"
|
||||||
|
): EventFormTempData | null {
|
||||||
|
try {
|
||||||
|
const key = `${STORAGE_KEY_PREFIX}${modalType}`;
|
||||||
|
const data = sessionStorage.getItem(key);
|
||||||
|
if (data) {
|
||||||
|
return JSON.parse(data) as EventFormTempData;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to restore form data from temp storage:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearEventFormTempData(modalType: "create" | "update"): void {
|
||||||
|
try {
|
||||||
|
const key = `${STORAGE_KEY_PREFIX}${modalType}`;
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to clear form data from temp storage:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showErrorNotification(message: string): void {
|
||||||
|
console.error(`[ERROR] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventFormState {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
location: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
allday: boolean;
|
||||||
|
repetition: RepetitionObject;
|
||||||
|
attendees: userAttendee[];
|
||||||
|
alarm: string;
|
||||||
|
busy: string;
|
||||||
|
eventClass: string;
|
||||||
|
timezone: string;
|
||||||
|
calendarid: string;
|
||||||
|
hasVideoConference: boolean;
|
||||||
|
meetingLink: string | null;
|
||||||
|
showMore?: boolean;
|
||||||
|
showDescription?: boolean;
|
||||||
|
showRepeat?: boolean;
|
||||||
|
hasEndDateChanged?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventFormContext {
|
||||||
|
eventId?: string;
|
||||||
|
calId?: string;
|
||||||
|
typeOfAction?: "solo" | "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEventFormTempData(
|
||||||
|
formState: EventFormState,
|
||||||
|
context?: EventFormContext
|
||||||
|
): EventFormTempData {
|
||||||
|
return {
|
||||||
|
...formState,
|
||||||
|
...context,
|
||||||
|
fromError: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventFormSetters {
|
||||||
|
setTitle: (value: string) => void;
|
||||||
|
setDescription: (value: string) => void;
|
||||||
|
setLocation: (value: string) => void;
|
||||||
|
setStart: (value: string) => void;
|
||||||
|
setEnd: (value: string) => void;
|
||||||
|
setAllDay: (value: boolean) => void;
|
||||||
|
setRepetition: (value: RepetitionObject) => void;
|
||||||
|
setAttendees: (value: userAttendee[]) => void;
|
||||||
|
setAlarm: (value: string) => void;
|
||||||
|
setBusy: (value: string) => void;
|
||||||
|
setEventClass: (value: string) => void;
|
||||||
|
setTimezone: (value: string) => void;
|
||||||
|
setCalendarid: (value: string) => void;
|
||||||
|
setHasVideoConference: (value: boolean) => void;
|
||||||
|
setMeetingLink: (value: string | null) => void;
|
||||||
|
setShowMore?: (value: boolean) => void;
|
||||||
|
setShowDescription?: (value: boolean) => void;
|
||||||
|
setShowRepeat?: (value: boolean) => void;
|
||||||
|
setHasEndDateChanged?: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreFormDataFromTemp(
|
||||||
|
tempData: EventFormTempData,
|
||||||
|
setters: EventFormSetters
|
||||||
|
): void {
|
||||||
|
setters.setTitle(tempData.title);
|
||||||
|
setters.setDescription(tempData.description);
|
||||||
|
setters.setLocation(tempData.location);
|
||||||
|
setters.setStart(tempData.start);
|
||||||
|
setters.setEnd(tempData.end);
|
||||||
|
setters.setAllDay(tempData.allday);
|
||||||
|
setters.setRepetition(tempData.repetition);
|
||||||
|
setters.setAttendees(tempData.attendees);
|
||||||
|
setters.setAlarm(tempData.alarm);
|
||||||
|
setters.setBusy(tempData.busy);
|
||||||
|
setters.setEventClass(tempData.eventClass);
|
||||||
|
setters.setTimezone(tempData.timezone);
|
||||||
|
setters.setCalendarid(tempData.calendarid);
|
||||||
|
setters.setHasVideoConference(tempData.hasVideoConference);
|
||||||
|
setters.setMeetingLink(tempData.meetingLink);
|
||||||
|
if (tempData.showMore !== undefined && setters.setShowMore) {
|
||||||
|
setters.setShowMore(tempData.showMore);
|
||||||
|
}
|
||||||
|
if (tempData.showDescription !== undefined && setters.setShowDescription) {
|
||||||
|
setters.setShowDescription(tempData.showDescription);
|
||||||
|
}
|
||||||
|
if (tempData.showRepeat !== undefined && setters.setShowRepeat) {
|
||||||
|
setters.setShowRepeat(tempData.showRepeat);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
tempData.hasEndDateChanged !== undefined &&
|
||||||
|
setters.setHasEndDateChanged
|
||||||
|
) {
|
||||||
|
setters.setHasEndDateChanged(tempData.hasEndDateChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user