import { Box, Button } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; import { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { ResponsiveDialog } from "../../components/Dialog"; import { putEventAsync, removeEvent, moveEventAsync, updateEventInstanceAsync, updateSeriesAsync, updateEventLocal, clearFetchCache, } from "../Calendars/CalendarSlice"; import { Calendars } from "../Calendars/CalendarTypes"; import { userAttendee } from "../User/userDataTypes"; import { CalendarEvent, RepetitionObject } from "./EventsTypes"; import { TIMEZONES } from "../../utils/timezone-data"; import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils"; import EventFormFields from "../../components/Event/EventFormFields"; import { formatDateTimeInTimezone } from "../../components/Event/utils/dateTimeFormatters"; import { addDays } from "../../components/Event/utils/dateRules"; import { getEvent, deleteEvent, putEvent } from "./EventApi"; import { refreshCalendars } from "../../components/Event/utils/eventUtils"; import { getCalendarRange } from "../../utils/dateUtils"; import { combineMasterDateWithFormTime, detectRecurringEventChanges, } from "./eventUtils"; import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils"; import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; import { updateAttendeesAfterTimeChange } from "../../components/Calendar/handlers/eventHandlers"; import { convertFormDateTimeToISO } from "../../components/Event/utils/dateTimeHelpers"; import { saveEventFormDataToTemp, restoreEventFormDataFromTemp as restoreEventFormDataFromStorage, clearEventFormTempData, showErrorNotification, buildEventFormTempData, restoreFormDataFromTemp, EventFormState, EventFormContext, } from "../../utils/eventFormTempStorage"; function EventUpdateModal({ eventId, calId, open, onClose, onCloseAll, eventData, typeOfAction, }: { eventId: string; calId: string; open: boolean; onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void; onCloseAll?: () => void; eventData?: CalendarEvent | null; typeOfAction?: "solo" | "all"; }) { const { t } = useI18n(); const dispatch = useAppDispatch(); const tempList = useAppSelector((state) => state.calendars.templist); const calList = useAppSelector((state) => state.calendars.list); // Get event from Redux store (cached data) as fallback const cachedEvent = useAppSelector( (state) => state.calendars.list[calId]?.events[eventId] ); // State for fresh event data const [freshEvent, setFreshEvent] = useState(null); // Use fresh data if available, otherwise use eventData from props, otherwise use cached data const event = freshEvent || eventData || cachedEvent; // Fetch fresh event data when modal opens useEffect(() => { if (open && cachedEvent && !eventData) { const fetchFreshData = async () => { try { const freshData = await getEvent(cachedEvent); setFreshEvent(freshData); } catch (err) { // Keep using cached data if API fails } }; fetchFreshData(); } }, [open, cachedEvent, eventData]); const user = useAppSelector((state) => state.user); const calendarsList = useAppSelector((state) => state.calendars.list); const userPersonalCalendars: Calendars[] = useMemo(() => { const allCalendars = Object.values(calendarsList) as Calendars[]; return allCalendars.filter( (c: Calendars) => c.id?.split("/")[0] === user.userData?.openpaasId ); }, [calendarsList, user.userData?.openpaasId]); // Helper function to resolve timezone aliases const resolveTimezone = (tzName: string): string => { if (TIMEZONES.zones[tzName]) { return tzName; } if (TIMEZONES.aliases[tzName]) { return TIMEZONES.aliases[tzName].aliasTo; } return tzName; }; const timezoneList = useMemo(() => { const zones = Object.keys(TIMEZONES.zones).sort(); const browserTz = resolveTimezone( Intl.DateTimeFormat().resolvedOptions().timeZone ); const getTimezoneOffset = (tzName: string): string => { const resolvedTz = resolveTimezone(tzName); const tzData = TIMEZONES.zones[resolvedTz]; if (!tzData) return ""; const icsMatch = tzData.ics.match(/TZOFFSETTO:([+-]\d{4})/); if (!icsMatch) return ""; const offset = icsMatch[1]; const hours = parseInt(offset.slice(0, 3)); const minutes = parseInt(offset.slice(3)); if (minutes === 0) { return `UTC${hours >= 0 ? "+" : ""}${hours}`; } return `UTC${hours >= 0 ? "+" : ""}${hours}:${Math.abs(minutes).toString().padStart(2, "0")}`; }; return { zones, browserTz, getTimezoneOffset }; }, []); const [showMore, setShowMore] = useState(false); const [showDescription, setShowDescription] = useState( event?.description ? true : false ); const [showRepeat, setShowRepeat] = useState( event?.repetition?.freq ? true : false ); // Form state - initialize with empty values const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [location, setLocation] = useState(""); const [start, setStart] = useState(""); const [end, setEnd] = useState(""); const [allday, setAllDay] = useState(false); const [repetition, setRepetition] = useState( {} as RepetitionObject ); const [alarm, setAlarm] = useState(""); const [busy, setBusy] = useState("OPAQUE"); const [eventClass, setEventClass] = useState("PUBLIC"); const [timezone, setTimezone] = useState( resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone) ); const [newCalId, setNewCalId] = useState(calId); const defaultCalendarId = useMemo( () => userPersonalCalendars[0]?.id ?? "", [userPersonalCalendars] ); const [calendarid, setCalendarid] = useState(calId ?? defaultCalendarId); const [attendees, setAttendees] = useState([]); const [hasVideoConference, setHasVideoConference] = useState(false); const [meetingLink, setMeetingLink] = useState(null); const [isFormValid, setIsFormValid] = useState(false); const [showValidationErrors, setShowValidationErrors] = useState(false); const [hasEndDateChanged, setHasEndDateChanged] = useState(false); const resetAllStateToDefault = useCallback(() => { setShowMore(false); setShowDescription(false); setShowRepeat(false); setTitle(""); setDescription(""); setAttendees([]); setLocation(""); setStart(""); setEnd(""); if (defaultCalendarId) { setCalendarid(defaultCalendarId); } setAllDay(false); setRepetition({} as RepetitionObject); setAlarm(""); setEventClass("PUBLIC"); setBusy("OPAQUE"); setTimezone( resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone) ); setHasVideoConference(false); setMeetingLink(null); }, [defaultCalendarId]); // 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); // Editing existing event - populate fields with event data setTitle(event.title ?? ""); setDescription(event.description ?? ""); setLocation(event.location ?? ""); // Handle all-day events properly const isAllDay = event.allday ?? false; setAllDay(isAllDay); // Get event's original timezone const eventTimezone = event.timezone ? resolveTimezone(event.timezone) : resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone); // Format dates based on all-day status if (event.start) { if (isAllDay) { // For all-day events, use date format (YYYY-MM-DD) const startDate = new Date(event.start); setStart(startDate.toISOString().split("T")[0]); } else { // For timed events, format in the event's original timezone setStart(formatDateTimeInTimezone(event.start, eventTimezone)); } } else { setStart(""); } if (event.end) { if (isAllDay) { // For all-day events, use date format (YYYY-MM-DD) const endDate = new Date(event.end); setEnd(endDate.toISOString().split("T")[0]); } else { // For timed events, format in the event's original timezone setEnd(formatDateTimeInTimezone(event.end, eventTimezone)); } } else { setEnd(""); } // Find correct calendar index setCalendarid(calId); // Handle repetition properly - check both current event and base event const baseEventId = event.uid.split("/")[0]; const baseEvent = calendarsList[calId]?.events[baseEventId]; const repetitionSource = event.repetition || baseEvent?.repetition; if (repetitionSource && repetitionSource.freq) { const repetitionData: RepetitionObject = { freq: repetitionSource.freq, interval: repetitionSource.interval || 1, occurrences: repetitionSource.occurrences, endDate: repetitionSource.endDate, byday: repetitionSource.byday || null, }; setRepetition(repetitionData); setShowRepeat(true); } else { setRepetition({} as RepetitionObject); setShowRepeat(false); } setAttendees( event.attendee ? event.attendee.filter( (a: userAttendee) => a.cal_address !== event.organizer?.cal_address ) : [] ); setAlarm(event.alarm?.trigger ?? ""); setEventClass(event.class ?? "PUBLIC"); setBusy(event.transp ?? "OPAQUE"); if (event.timezone) { const resolvedTimezone = resolveTimezone(event.timezone); setTimezone(resolvedTimezone); } else { const browserTz = resolveTimezone( Intl.DateTimeFormat().resolvedOptions().timeZone ); setTimezone(browserTz); } setHasVideoConference(event.x_openpass_videoconference ? true : false); setMeetingLink(event.x_openpass_videoconference || null); setNewCalId(event.calId || calId); // Update description to include video conference footer if exists if (event.x_openpass_videoconference && event.description) { const hasVideoFooter = event.description.includes("Visio:"); if (!hasVideoFooter) { setDescription( addVideoConferenceToDescription( event.description, event.x_openpass_videoconference ) ); } else { setDescription(event.description); } } } }, [open, event, calId, userPersonalCalendars, calendarsList]); // Helper to close modal(s) - use onCloseAll if available to close preview modal too const closeModal = () => { if (onCloseAll) { onCloseAll(); } else { onClose({}, "backdropClick"); } }; const handleClose = () => { // Clear temp data when user manually closes modal clearEventFormTempData("update"); closeModal(); setShowValidationErrors(false); resetAllStateToDefault(); setFreshEvent(null); initializedKeyRef.current = null; }; // Function to save current form data to temp storage const saveCurrentFormData = useCallback(() => { const formState: EventFormState = { title, description, location, start, end, allday, repetition, attendees, alarm, busy, eventClass, timezone, calendarid, hasVideoConference, meetingLink, showMore, showDescription, showRepeat, hasEndDateChanged, }; const context: EventFormContext = { eventId, calId, typeOfAction, }; return buildEventFormTempData(formState, context); }, [ title, description, location, start, end, allday, repetition, attendees, alarm, busy, eventClass, timezone, calendarid, hasVideoConference, meetingLink, showMore, showDescription, showRepeat, hasEndDateChanged, eventId, calId, typeOfAction, ]); // Check for temp data when modal opens useEffect(() => { if (open && event) { const tempData = restoreEventFormDataFromStorage("update"); if ( tempData && tempData.fromError && tempData.eventId === eventId && tempData.calId === calId && tempData.typeOfAction === typeOfAction ) { // Mark that we're restoring from error to prevent other useEffects from overriding isRestoringFromErrorRef.current = true; // Restore form data from previous failed save restoreFormDataFromTemp(tempData, { setTitle, setDescription, setLocation, setStart, setEnd, setAllDay, setRepetition, setAttendees, setAlarm, setBusy, setEventClass, setTimezone, setCalendarid, setHasVideoConference, setMeetingLink, setShowMore, setShowDescription, setShowRepeat, setHasEndDateChanged, }); // Clear the error flag but keep data until successful save const updatedTempData = { ...tempData, fromError: false }; saveEventFormDataToTemp("update", updatedTempData); // Reset flag after restore completes (use setTimeout to ensure other useEffects have checked the flag) setTimeout(() => { isRestoringFromErrorRef.current = false; }, 0); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, eventId, calId, typeOfAction]); const handleSave = async () => { // Show validation errors when Save is clicked setShowValidationErrors(true); // Check if form is valid before saving if (!isFormValid) { return; } if (!event) { return; } const organizer = event.organizer; const targetCalendar = calList[calendarid]; if (!targetCalendar) { console.error("Target calendar not found"); return; } // Reset validation state when validation passes setShowValidationErrors(false); // Handle recurrence instances const [baseUID, recurrenceId] = event.uid.split("/"); // 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") { try { // Fetch master event using base UID (without recurrence-id) const masterEventToFetch = { ...event, uid: baseUID, // Use base UID to get master event }; const masterEvent = await getEvent(masterEventToFetch, true); masterEventData = masterEvent; } catch (err: any) { // API failed - restore form data and mark as error 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; } } // Handle start and end dates based on all-day status let startDate: string; let endDate: string; // For "all events" update, use master event's DATE but apply user's TIME from form if (masterEventData && typeOfAction === "all") { const combined = combineMasterDateWithFormTime( masterEventData, start, end, timezone, allday, formatDateTimeInTimezone ); startDate = combined.startDate; endDate = combined.endDate; } else { // For single events or "solo" edits, use the edited dates from form if (allday) { // For all-day events, use date format (YYYY-MM-DD) // Extract date string directly to avoid timezone conversion issues const startDateOnly = (start || "").split("T")[0]; const endDateOnlyUI = (end || start || "").split("T")[0]; // API needs end date = UI end date + 1 day const endDateOnlyAPI = addDays(endDateOnlyUI, 1); // Parse date string and create Date at UTC midnight to avoid timezone offset issues const [startYear, startMonth, startDay] = startDateOnly .split("-") .map(Number); const [endYear, endMonth, endDay] = endDateOnlyAPI .split("-") .map(Number); const startDateObj = new Date( Date.UTC(startYear, startMonth - 1, startDay, 0, 0, 0, 0) ); const endDateObj = new Date( Date.UTC(endYear, endMonth - 1, endDay, 0, 0, 0, 0) ); startDate = startDateObj.toISOString(); endDate = endDateObj.toISOString(); } else { // For timed events startDate = convertFormDateTimeToISO(start, timezone); // In normal mode, only override end date when the end date field is not shown if (!showMore && !hasEndDateChanged) { const startDateOnly = (start || "").split("T")[0]; const endTimeOnly = end.includes("T") ? end.split("T")[1]?.slice(0, 5) || "00:00" : "00:00"; const endDateTime = `${startDateOnly}T${endTimeOnly}`; endDate = convertFormDateTimeToISO(endDateTime, timezone); } else { // Extended mode: use actual end datetime endDate = convertFormDateTimeToISO(end, timezone); } } } const eventStartChanged = new Date(event.start).getTime() !== new Date(startDate).getTime(); const eventEndChanged = new Date(event?.end ?? "").getTime() !== new Date(endDate).getTime(); const timeChanged = eventStartChanged || eventEndChanged; const newEvent: CalendarEvent = { ...updateAttendeesAfterTimeChange(event, timeChanged, attendees), calId: newCalId || calId, title, URL: event.URL ?? `/calendars/${newCalId || calId}/${event.uid}.ics`, start: startDate, end: endDate, allday, uid: event.uid, description, location, repetition, class: eventClass, organizer: organizer, timezone, transp: busy, color: targetCalendar?.color, alarm: { trigger: alarm, action: "EMAIL" }, x_openpass_videoconference: meetingLink || undefined, }; // Special case: When converting recurring event to non-recurring if ( recurrenceId && typeOfAction === "all" && event.repetition?.freq && !repetition.freq ) { 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 // Collect all instances that need to be deleted const instancesToDelete = Object.keys(targetCalendar.events) .filter((eventId) => eventId.split("/")[0] === baseUID) .map((eventId) => targetCalendar.events[eventId]); // Get unique URLs to avoid deleting same file multiple times const uniqueURLs = new Set(); const instancesByURL = new Map(); instancesToDelete.forEach((instance) => { if (!instancesByURL.has(instance.URL)) { instancesByURL.set(instance.URL, []); } instancesByURL.get(instance.URL)!.push(instance); uniqueURLs.add(instance.URL); }); // Delete each unique URL once const deletePromises = Array.from(uniqueURLs).map(async (url) => { try { await deleteEvent(url); } catch (deleteError: any) { // Silently ignore 404 - file might already be deleted const is404 = deleteError.response?.status === 404 || deleteError.message?.includes("404") || deleteError.message?.includes("Not Found"); if (!is404) { console.error( `Failed to delete event file: ${deleteError.message}` ); } } }); await Promise.all(deletePromises); // Small delay to ensure backend processes deletions await new Promise((resolve) => setTimeout(resolve, 100)); // STEP 2: Create new non-recurring event const newEventUID = crypto.randomUUID(); const finalNewEvent = { ...newEvent, uid: newEventUID, URL: `/calendars/${newCalId || calId}/${newEventUID}.ics`, }; // STEP 3: Persist new event to server await putEvent(finalNewEvent, targetCalendar.ownerEmails?.[0]); // STEP 4: Update Redux store - Add new event first to prevent empty grid dispatch(updateEventLocal({ calId, event: finalNewEvent })); createdSingleEventUid = finalNewEvent.uid; // Clear cache to ensure navigation to other weeks works dispatch(clearFetchCache(calId)); 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 try { if (recurrenceId) { if (typeOfAction === "solo") { // Update single instance with optimistic update + rollback const oldEvent = { ...event }; dispatch( updateEventLocal({ calId, event: { ...newEvent, recurrenceId }, }) ); try { const result = await dispatch( updateEventInstanceAsync({ cal: targetCalendar, event: { ...newEvent, recurrenceId }, }) ); // 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 }) ); }); }; 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, }, }) ); }); // Update server in background with removeOverrides=false const result = await dispatch( updateSeriesAsync({ cal: targetCalendar, event: { ...newEvent, recurrenceId }, removeOverrides: false, }) ); // 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" ); } } // 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 }, }) ); } }; const dialogActions = ( {!showMore && ( )} ); if (!event) return null; return ( setShowMore(!showMore)} actions={dialogActions} > { const selectedCalendar = calList[newCalendarId]; if (selectedCalendar) { setNewCalId(selectedCalendar.id); } }} onValidationChange={setIsFormValid} showValidationErrors={showValidationErrors} onHasEndDateChangedChange={setHasEndDateChanged} /> ); } export default EventUpdateModal;