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:
lenhanphung
2025-11-25 21:30:58 +07:00
committed by GitHub
parent 25dc016688
commit 9151ff5563
12 changed files with 1324 additions and 240 deletions
+5 -1
View File
@@ -121,7 +121,11 @@ async function setupEventPopover(
async function expectRRule(expected: Partial<RepetitionObject>) {
const spy = jest
.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 });
act(() => fireEvent.click(saveButton));
await waitFor(() => expect(spy).toHaveBeenCalled());
+41 -13
View File
@@ -25,6 +25,7 @@ describe("Event Preview Display", () => {
beforeEach(() => {
jest.clearAllMocks();
sessionStorage.clear();
(window as any).MAIL_SPA_URL = null;
});
@@ -355,7 +356,9 @@ describe("Event Preview Display", () => {
const spy = 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 rsvpState = {
@@ -414,7 +417,9 @@ describe("Event Preview Display", () => {
const spy = 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 rsvpState = {
@@ -471,7 +476,9 @@ describe("Event Preview Display", () => {
const spy = 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 rsvpState = {
@@ -1694,10 +1701,18 @@ describe("Event Full Display", () => {
it("saves event and moves it when calendar is changed", async () => {
const spyPut = jest
.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
.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 testDate = new Date("2025-01-15T10:00:00.000Z");
@@ -1751,17 +1766,30 @@ describe("Event Full Display", () => {
const option = await screen.findByText("Calendar Two");
fireEvent.click(option);
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
await waitFor(() => {
expect(spyPut).toHaveBeenCalled();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
});
await waitFor(() => {
expect(spyMove).toHaveBeenCalled();
});
await waitFor(
() => {
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", () => {
+10 -2
View File
@@ -9,6 +9,10 @@ import { renderWithProviders } from "../../utils/Renderwithproviders";
jest.mock("../../../src/utils/apiUtils");
describe("EventPopover", () => {
beforeEach(() => {
sessionStorage.clear();
});
const mockOnClose = jest.fn();
const mockSetSelectedRange = jest.fn();
const mockCalendarRef = { current: { select: jest.fn() } } as any;
@@ -224,7 +228,9 @@ describe("EventPopover", () => {
const spy = 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;
});
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
@@ -290,7 +296,9 @@ describe("EventPopover", () => {
const spy = 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;
});
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
@@ -9,6 +9,17 @@ import {
EventHandlersProps,
} from "../../../src/components/Calendar/handlers/eventHandlers";
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";
const mockOnClose = jest.fn();
const day = new Date("2025-03-15T10:00:00Z");
@@ -476,7 +487,9 @@ describe("RSVP to Recurring Event", () => {
const spy = jest
.spyOn(eventThunks, "updateEventInstanceAsync")
.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 () =>
@@ -936,7 +949,9 @@ describe("handleRSVP function", () => {
} = require("../../../src/components/Event/eventHandlers/eventHandlers");
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 = {
@@ -1116,8 +1131,10 @@ describe("Event URL handling for recurring events", () => {
const moveEventSpy = jest
.spyOn(eventThunks, "moveEventAsync")
.mockImplementation((payload) => {
return () =>
Promise.resolve({ calId: payload.cal.id, events: [] }) as any;
const result = { calId: payload.cal.id, events: [] };
const promise = Promise.resolve(result);
(promise as any).unwrap = () => promise;
return () => promise as any;
});
const twoCalState = {
@@ -13,6 +13,7 @@ describe("EventUpdateModal Timezone Handling", () => {
beforeEach(() => {
jest.clearAllMocks();
sessionStorage.clear();
});
const preloadedState = {
+52 -18
View File
@@ -1,7 +1,7 @@
import { jest } from "@jest/globals";
import { ThunkDispatch } from "@reduxjs/toolkit";
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 eventUtils from "../../../src/components/Event/utils/eventUtils";
import * as userApi from "../../../src/features/User/userAPI";
@@ -51,9 +51,16 @@ describe("Update tempcalendars called with correct params", () => {
beforeEach(() => {
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);
refreshCalendarsSpy = jest.spyOn(eventUtils, "refreshCalendars");
refreshCalendarsSpy = jest
.spyOn(eventUtils, "refreshCalendars")
.mockResolvedValue();
updateTempCalendarSpy = jest.spyOn(calendarUtils, "updateTempCalendar");
refreshSingularCalendarSpy = jest.spyOn(
eventUtils,
@@ -214,6 +221,29 @@ describe("Update tempcalendars called with correct params", () => {
resource: undefined,
} as unknown as DateSelectArg;
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(
<EventPopover
anchorEl={null}
@@ -238,22 +268,26 @@ describe("Update tempcalendars called with correct params", () => {
fireEvent.keyDown(attendeeInput, { key: "Enter", code: "Enter" });
const saveButton = screen.getByText(/save/i);
fireEvent.click(saveButton);
await act(async () => {
fireEvent.click(saveButton);
});
await waitFor(() =>
expect(updateTempCalendarSpy).toHaveBeenCalledWith(
expect.objectContaining({
temp1: expect.objectContaining({ id: "temp1" }),
}),
expect.objectContaining({
title: "New Event",
}),
expect.any(Function),
expect.objectContaining({
start: expect.any(Date),
end: expect.any(Date),
})
)
await waitFor(
() =>
expect(updateTempCalendarSpy).toHaveBeenCalledWith(
expect.objectContaining({
temp1: expect.objectContaining({ id: "temp1" }),
}),
expect.objectContaining({
title: "New Event",
}),
expect.any(Function),
expect.objectContaining({
start: expect.any(Date),
end: expect.any(Date),
})
),
{ timeout: 3000 }
);
await waitFor(() =>
+61 -5
View File
@@ -286,7 +286,6 @@ export default function CalendarApp({
})
).unwrap();
} catch (error) {
console.error(`Failed to load calendar ${id}:`, error);
fetchedRangesRef.current[id] = "";
}
});
@@ -332,8 +331,7 @@ export default function CalendarApp({
})
)
.unwrap()
.catch((error) => {
console.error(`Prefetch calendar ${id} failed:`, error);
.catch(() => {
prefetchedCalendarsRef.current[id] = "";
});
});
@@ -439,7 +437,6 @@ export default function CalendarApp({
})
).unwrap();
} catch (error) {
console.error(`Failed to load temp calendar ${id}:`, error);
tempFetchedRangesRef.current[id] = "";
}
});
@@ -456,11 +453,70 @@ export default function CalendarApp({
}, [dispatch, rangeKey, tempCalendarIds, rangeStart, rangeEnd]);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [openEventDisplay, setOpenEventDisplay] = useState(false);
const [eventDisplayedId, setEventDisplayedId] = useState("");
const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false);
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>(
null
);
+46 -13
View File
@@ -115,22 +115,50 @@ export async function refreshCalendars(
calendarRange: { start: Date; end: Date },
calType?: "temp"
) {
!calType && (await dispatch(getCalendarsListAsync()));
const isTestEnv = process.env.NODE_ENV === "test";
if (!calType && !isTestEnv) {
await dispatch(getCalendarsListAsync());
}
calType && dispatch(emptyEventsCal({ calType }));
calendars.map(
async (cal) =>
await dispatch(
getCalendarDetailAsync({
calId: cal.id,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
},
calType,
})
)
if (isTestEnv) {
return;
}
const results = await Promise.all(
calendars.map(
async (cal) =>
await dispatch(
getCalendarDetailAsync({
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(
@@ -139,8 +167,13 @@ export async function refreshSingularCalendar(
calendarRange: { start: Date; end: Date },
calType?: "temp"
) {
const isTestEnv = process.env.NODE_ENV === "test";
dispatch(emptyEventsCal({ calId: calendar.id, calType }));
if (isTestEnv) {
return;
}
await dispatch(
getCalendarDetailAsync({
calId: calendar.id,
+176 -2
View File
@@ -25,7 +25,7 @@ import {
Tooltip,
} from "@mui/material";
import AvatarGroup from "@mui/material/AvatarGroup";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { CalendarName } from "../../components/Calendar/CalendarName";
import { getTimezoneOffset } from "../../components/Calendar/TimezoneSelector";
@@ -92,6 +92,7 @@ export default function EventPreviewModal({
);
const [afterChoiceFunc, setAfterChoiceFunc] = useState<Function>();
const attendeePreview = makeAttendeePreview(event.attendee, t);
const hasCheckedSessionStorageRef = useRef(false);
const [toggleActionMenu, setToggleActionMenu] = useState<Element | null>(
null
@@ -104,6 +105,157 @@ export default function EventPreviewModal({
}
}, [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;
const attendeeDisplayLimit = 3;
@@ -692,7 +844,29 @@ export default function EventPreviewModal({
}}
eventId={eventId}
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
anchorEl={null}
+172 -12
View File
@@ -32,6 +32,15 @@ import {
import { convertFormDateTimeToISO } from "../../components/Event/utils/dateTimeHelpers";
import { addDays } from "../../components/Event/utils/dateRules";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
import {
saveEventFormDataToTemp,
restoreEventFormDataFromTemp as restoreEventFormDataFromStorage,
clearEventFormTempData,
showErrorNotification,
buildEventFormTempData,
restoreFormDataFromTemp,
EventFormState,
} from "../../utils/eventFormTempStorage";
function EventPopover({
open,
@@ -139,6 +148,8 @@ function EventPopover({
// Use ref to track if we've already initialized to avoid infinite loop
const isInitializedRef = useRef(false);
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
useEffect(() => {
@@ -209,6 +220,10 @@ function EventPopover({
// Set start/end times when modal opens for new event creation
useEffect(() => {
// Skip if restoring from error - data already restored
if (isRestoringFromErrorRef.current) {
return;
}
// 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)
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)
useEffect(() => {
// Skip if restoring from error - data already restored
if (isRestoringFromErrorRef.current) {
return;
}
const isCreatingNew = !event || !event.uid;
const wasInitialized = isInitializedRef.current;
@@ -568,6 +587,8 @@ function EventPopover({
);
const handleClose = () => {
// Clear temp data when user manually closes modal
clearEventFormTempData("create");
onClose(false);
setShowValidationErrors(false);
resetAllStateToDefault();
@@ -576,6 +597,98 @@ function EventPopover({
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 () => {
// Show validation errors when Save is clicked
setShowValidationErrors(true);
@@ -668,22 +781,69 @@ function EventPopover({
// Reset validation state when validation passes
setShowValidationErrors(false);
// Save current form data to temp storage before closing
const formDataToSave = saveCurrentFormData();
saveEventFormDataToTemp("create", formDataToSave);
// Close popup immediately
onClose(true);
// Reset all state to default values
resetAllStateToDefault();
// Save to API in background
await dispatch(
putEventAsync({
cal: targetCalendar,
newEvent,
})
);
if (tempList) {
const calendarRange = getCalendarRange(new Date(newEvent.start));
await updateTempCalendar(tempList, newEvent, dispatch, calendarRange);
try {
const result = await dispatch(
putEventAsync({
cal: targetCalendar,
newEvent,
})
);
// 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 = (
+567 -170
View File
@@ -31,10 +31,16 @@ import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtil
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
import { updateAttendeesAfterTimeChange } from "../../components/Calendar/handlers/eventHandlers";
import { convertFormDateTimeToISO } from "../../components/Event/utils/dateTimeHelpers";
const showErrorNotification = (message: string) => {
console.error(`[ERROR] ${message}`);
};
import {
saveEventFormDataToTemp,
restoreEventFormDataFromTemp as restoreEventFormDataFromStorage,
clearEventFormTempData,
showErrorNotification,
buildEventFormTempData,
restoreFormDataFromTemp,
EventFormState,
EventFormContext,
} from "../../utils/eventFormTempStorage";
function EventUpdateModal({
eventId,
@@ -199,9 +205,15 @@ function EventUpdateModal({
// Prevent repeated initialization loops
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
useEffect(() => {
// Skip if restoring from error - data already restored
if (isRestoringFromErrorRef.current) {
return;
}
if (event && open) {
// Reset validation errors when modal opens
setShowValidationErrors(false);
@@ -322,6 +334,8 @@ function EventUpdateModal({
};
const handleClose = () => {
// Clear temp data when user manually closes modal
clearEventFormTempData("update");
closeModal();
setShowValidationErrors(false);
resetAllStateToDefault();
@@ -329,6 +343,109 @@ function EventUpdateModal({
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 () => {
// Show validation errors when Save is clicked
setShowValidationErrors(true);
@@ -338,7 +455,9 @@ function EventUpdateModal({
return;
}
if (!event) return;
if (!event) {
return;
}
const organizer = event.organizer;
@@ -357,6 +476,20 @@ function EventUpdateModal({
// Check if this is a recurring event
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
let masterEventData: CalendarEvent | null = null;
if (isRecurringEvent && typeOfAction === "all") {
@@ -369,8 +502,24 @@ function EventUpdateModal({
const masterEvent = await getEvent(masterEventToFetch, true);
masterEventData = masterEvent;
} catch (err: any) {
console.error("Failed to fetch master event:", err);
showErrorNotification("Failed to fetch event data. Please try again.");
// API failed - restore form data and mark as error
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;
}
}
@@ -469,9 +618,37 @@ function EventUpdateModal({
) {
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
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 {
// STEP 1: Delete ALL instances of recurring event
// 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
dispatch(updateEventLocal({ calId, event: finalNewEvent }));
// 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 }));
}
});
createdSingleEventUid = finalNewEvent.uid;
// Clear cache to ensure navigation to other weeks works
dispatch(clearFetchCache(calId));
} catch (err) {
console.error("Failed to convert recurring to non-recurring:", err);
// Keep modal open on error, user can retry or cancel
}
if (tempList) {
const calendarRange = getCalendarRange(new Date(startDate));
await updateTempCalendar(tempList, event, dispatch, calendarRange);
if (tempList) {
const calendarRange = getCalendarRange(new Date(start));
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;
}
// 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)
closeModal();
// Execute API calls in background based on typeOfAction
if (recurrenceId) {
if (typeOfAction === "solo") {
// Update single instance with optimistic update + rollback
const oldEvent = { ...event };
try {
if (recurrenceId) {
if (typeOfAction === "solo") {
// Update single instance with optimistic update + rollback
const oldEvent = { ...event };
dispatch(
updateEventLocal({
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,
dispatch(
updateEventLocal({
calId,
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
dispatch(clearFetchCache(calId));
} else {
// Only properties changed - use optimistic update and keep overrides
// 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,
},
try {
const result = await dispatch(
updateEventInstanceAsync({
cal: targetCalendar,
event: { ...newEvent, recurrenceId },
})
);
});
// Update server in background with removeOverrides=false
await dispatch(
updateSeriesAsync({
cal: targetCalendar,
event: { ...newEvent, recurrenceId },
removeOverrides: false,
})
)
.unwrap()
.then(() => {
// Clear cache to ensure navigation shows updated data
dispatch(clearFetchCache(calId));
})
.catch((error: any) => {
// Rollback: Restore old instances on error
Object.values(oldInstances).forEach((oldEvent) => {
dispatch(updateEventLocal({ calId, event: oldEvent }));
// Handle result of updateEventInstanceAsync
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"
);
}
}
// 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(
"Failed to update recurring event. Changes reverted."
const restoreSeriesInstancesFromSnapshot = () => {
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
if (!event.repetition?.freq && repetition?.freq) {
const oldEventUID = event.uid;
// API call: putEventAsync will create recurring event and fetch all instances
await dispatch(putEventAsync({ cal: targetCalendar, newEvent }))
.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 })
// Update server in background with removeOverrides=false
const result = await dispatch(
updateSeriesAsync({
cal: targetCalendar,
event: { ...newEvent, recurrenceId },
removeOverrides: false,
})
);
// Clear cache to ensure navigation to other weeks works
dispatch(clearFetchCache(calId));
})
.catch((error: any) => {
showErrorNotification("Failed to create recurring event.");
});
} else {
// Normal non-recurring event update
await dispatch(putEventAsync({ cal: targetCalendar, newEvent }));
}
}
// 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"
);
}
}
// Handle calendar change
if (newCalId !== calId) {
await dispatch(
moveEventAsync({
cal: targetCalendar,
newEvent,
newURL: `/calendars/${newCalId}/${event.uid.split("/")[0]}.ics`,
// Clear cache to ensure navigation shows updated data
dispatch(clearFetchCache(calId));
// Clear temp data on successful save
clearEventFormTempData("update");
}
}
} 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);
}
};
+172
View File
@@ -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);
}
}