From 9151ff5563f486453f18252d538a2091155a6fa1 Mon Sep 17 00:00:00 2001 From: lenhanphung <44486647+lenhanphung@users.noreply.github.com> Date: Tue, 25 Nov 2025 21:30:58 +0700 Subject: [PATCH] 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 --- __test__/components/RepeatEvent.test.tsx | 6 +- .../features/Events/EventDisplay.test.tsx | 54 +- __test__/features/Events/EventModal.test.tsx | 12 +- .../features/Events/EventRepetition.test.tsx | 25 +- .../features/Events/EventUpdateModal.test.tsx | 1 + __test__/features/Events/TempUpdate.test.tsx | 70 +- src/components/Calendar/Calendar.tsx | 66 +- src/components/Event/utils/eventUtils.tsx | 59 +- src/features/Events/EventDisplayPreview.tsx | 178 ++++- src/features/Events/EventModal.tsx | 184 ++++- src/features/Events/EventUpdateModal.tsx | 737 ++++++++++++++---- src/utils/eventFormTempStorage.ts | 172 ++++ 12 files changed, 1324 insertions(+), 240 deletions(-) create mode 100644 src/utils/eventFormTempStorage.ts diff --git a/__test__/components/RepeatEvent.test.tsx b/__test__/components/RepeatEvent.test.tsx index 99495e7..934d973 100644 --- a/__test__/components/RepeatEvent.test.tsx +++ b/__test__/components/RepeatEvent.test.tsx @@ -121,7 +121,11 @@ async function setupEventPopover( async function expectRRule(expected: Partial) { 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()); diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index 742f94b..6379637 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -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", () => { diff --git a/__test__/features/Events/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx index 1a8cdeb..d9a83bd 100644 --- a/__test__/features/Events/EventModal.test.tsx +++ b/__test__/features/Events/EventModal.test.tsx @@ -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" })); diff --git a/__test__/features/Events/EventRepetition.test.tsx b/__test__/features/Events/EventRepetition.test.tsx index b454497..5554ab5 100644 --- a/__test__/features/Events/EventRepetition.test.tsx +++ b/__test__/features/Events/EventRepetition.test.tsx @@ -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 = { diff --git a/__test__/features/Events/EventUpdateModal.test.tsx b/__test__/features/Events/EventUpdateModal.test.tsx index 9da1cc2..0229604 100644 --- a/__test__/features/Events/EventUpdateModal.test.tsx +++ b/__test__/features/Events/EventUpdateModal.test.tsx @@ -13,6 +13,7 @@ describe("EventUpdateModal Timezone Handling", () => { beforeEach(() => { jest.clearAllMocks(); + sessionStorage.clear(); }); const preloadedState = { diff --git a/__test__/features/Events/TempUpdate.test.tsx b/__test__/features/Events/TempUpdate.test.tsx index 97bc7e4..2d9e064 100644 --- a/__test__/features/Events/TempUpdate.test.tsx +++ b/__test__/features/Events/TempUpdate.test.tsx @@ -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; + const dispatch = jest.fn((thunk) => { + if (typeof thunk === "function") { + return thunk(dispatch, () => ({}), undefined); + } + return thunk; + }) as ThunkDispatch; 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( { 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(() => diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index a798a67..895be14 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -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(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( null ); diff --git a/src/components/Event/utils/eventUtils.tsx b/src/components/Event/utils/eventUtils.tsx index 306c4db..fc1fdbf 100644 --- a/src/components/Event/utils/eventUtils.tsx +++ b/src/components/Event/utils/eventUtils.tsx @@ -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, diff --git a/src/features/Events/EventDisplayPreview.tsx b/src/features/Events/EventDisplayPreview.tsx index 4c1a4f9..d2c0b93 100644 --- a/src/features/Events/EventDisplayPreview.tsx +++ b/src/features/Events/EventDisplayPreview.tsx @@ -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(); const attendeePreview = makeAttendeePreview(event.attendee, t); + const hasCheckedSessionStorageRef = useRef(false); const [toggleActionMenu, setToggleActionMenu] = useState( 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; + })()} /> { @@ -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 = ( diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx index 13bd72e..583e80d 100644 --- a/src/features/Events/EventUpdateModal.tsx +++ b/src/features/Events/EventUpdateModal.tsx @@ -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(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 => { + const instances: Record = {}; + 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 = {}; - 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); } }; diff --git a/src/utils/eventFormTempStorage.ts b/src/utils/eventFormTempStorage.ts new file mode 100644 index 0000000..cb53a0a --- /dev/null +++ b/src/utils/eventFormTempStorage.ts @@ -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); + } +}