Files
workavia-calendar-front/src/features/Events/EventUpdateModal.tsx
T
lenhanphung 9151ff5563 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
2025-11-25 15:30:58 +01:00

1209 lines
40 KiB
TypeScript

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<CalendarEvent | null>(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<RepetitionObject>(
{} 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<userAttendee[]>([]);
const [hasVideoConference, setHasVideoConference] = useState(false);
const [meetingLink, setMeetingLink] = useState<string | null>(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<string | null>(null);
// Track when restoring from error to prevent other useEffects from overriding restored data
const isRestoringFromErrorRef = useRef(false);
// Initialize form state when event data is available
useEffect(() => {
// Skip if restoring from error - data already restored
if (isRestoringFromErrorRef.current) {
return;
}
if (event && open) {
// Reset validation errors when modal opens
setShowValidationErrors(false);
// 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<string, CalendarEvent> => {
const instances: Record<string, CalendarEvent> = {};
const seriesEvents = targetCalendar.events || {};
Object.keys(seriesEvents).forEach((eventId) => {
const instance = seriesEvents[eventId];
if (instance && eventId.split("/")[0] === baseUID) {
instances[eventId] = { ...instance };
}
});
return instances;
};
// When editing "all events" of a recurring series, fetch master event to get original start time
let masterEventData: CalendarEvent | null = null;
if (isRecurringEvent && typeOfAction === "all") {
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<string>();
const instancesByURL = new Map<string, CalendarEvent[]>();
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 = (
<Box display="flex" justifyContent="space-between" width="100%" px={2}>
{!showMore && (
<Button startIcon={<AddIcon />} onClick={() => setShowMore(!showMore)}>
{t("common.moreOptions")}
</Button>
)}
<Box display="flex" gap={1} ml={showMore ? "auto" : 0}>
<Button variant="outlined" onClick={handleClose}>
{t("common.cancel")}
</Button>
<Button variant="contained" onClick={handleSave}>
{t("actions.save")}
</Button>
</Box>
</Box>
);
if (!event) return null;
return (
<ResponsiveDialog
open={open}
onClose={handleClose}
title={t("event.updateEvent")}
isExpanded={showMore}
onExpandToggle={() => setShowMore(!showMore)}
actions={dialogActions}
>
<EventFormFields
title={title}
setTitle={setTitle}
description={description}
setDescription={setDescription}
location={location}
setLocation={setLocation}
start={start}
setStart={setStart}
end={end}
setEnd={setEnd}
allday={allday}
setAllDay={setAllDay}
repetition={repetition}
setRepetition={setRepetition}
typeOfAction={typeOfAction}
attendees={attendees}
setAttendees={setAttendees}
alarm={alarm}
setAlarm={setAlarm}
busy={busy}
setBusy={setBusy}
eventClass={eventClass}
setEventClass={setEventClass}
timezone={timezone}
setTimezone={setTimezone}
calendarid={calendarid}
setCalendarid={setCalendarid}
hasVideoConference={hasVideoConference}
setHasVideoConference={setHasVideoConference}
meetingLink={meetingLink}
setMeetingLink={setMeetingLink}
showMore={showMore}
showDescription={showDescription}
setShowDescription={setShowDescription}
showRepeat={typeOfAction !== "solo" && showRepeat}
setShowRepeat={setShowRepeat}
isOpen={open}
userPersonalCalendars={userPersonalCalendars}
timezoneList={timezoneList}
onCalendarChange={(newCalendarId) => {
const selectedCalendar = calList[newCalendarId];
if (selectedCalendar) {
setNewCalId(selectedCalendar.id);
}
}}
onValidationChange={setIsFormValid}
showValidationErrors={showValidationErrors}
onHasEndDateChangedChange={setHasEndDateChanged}
/>
</ResponsiveDialog>
);
}
export default EventUpdateModal;