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
+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 = (