Files
workavia-calendar-front/src/features/Events/EventUpdateModal.tsx
T
2026-03-13 09:49:36 +01:00

1097 lines
36 KiB
TypeScript

import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { ResponsiveDialog } from "@/components/Dialog";
import EventFormFields from "@/components/Event/EventFormFields";
import { addDays } from "@/components/Event/utils/dateRules";
import { formatDateTimeInTimezone } from "@/components/Event/utils/dateTimeFormatters";
import { convertFormDateTimeToISO } from "@/components/Event/utils/dateTimeHelpers";
import {
putEventAsync,
updateEventInstanceAsync,
updateSeriesAsync,
} from "@/features/Calendars/services";
import { updateAttendeesAfterTimeChange } from "@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange";
import { assertThunkSuccess } from "@/utils/assertThunkSuccess";
import {
buildEventFormTempData,
clearEventFormTempData,
EventFormContext,
EventFormState,
restoreEventFormDataFromTemp as restoreEventFormDataFromStorage,
restoreFormDataFromTemp,
saveEventFormDataToTemp,
showErrorNotification,
} from "@/utils/eventFormTempStorage";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import {
browserDefaultTimeZone,
getTimezoneOffset,
resolveTimezone,
} from "@/utils/timezone";
import { TIMEZONES } from "@/utils/timezone-data";
import { addVideoConferenceToDescription } from "@/utils/videoConferenceUtils";
import { Box, Button } from "@linagora/twake-mui";
import AddIcon from "@mui/icons-material/Add";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "twake-i18n";
import {
clearFetchCache,
removeEvent,
updateEventLocal,
} from "../Calendars/CalendarSlice";
import { Calendar } from "../Calendars/CalendarTypes";
import { userAttendee } from "../User/models/attendee";
import { deleteEvent, getEvent, putEvent } from "./EventApi";
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { detectRecurringEventChanges } from "./eventUtils";
import { moveEventBetweenCalendars } from "./updateEventHelpers/moveEventBetweenCalendars";
function EventUpdateModal({
eventId,
calId,
open,
onClose,
onCloseAll,
eventData,
typeOfAction,
}: {
eventId: string;
calId: string;
open: boolean;
onClose: (event: unknown, reason: "backdropClick" | "escapeKeyDown") => void;
onCloseAll?: () => void;
eventData?: CalendarEvent | null;
typeOfAction?: "solo" | "all";
}) {
const { t } = useI18n();
const dispatch = useAppDispatch();
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]
);
// Use fresh data if available, otherwise use eventData from props, otherwise use cached data
const event = eventData || cachedEvent;
const user = useAppSelector((state) => state.user);
const userPersonalCalendars: Calendar[] = useMemo(() => {
const allCalendars = Object.values(calList) as Calendar[];
return allCalendars.filter(
(calendar: Calendar) =>
calendar.id?.split("/")[0] === user.userData?.openpaasId ||
(calendar.delegated && calendar.access?.write)
);
}, [calList, user.userData?.openpaasId]);
const timezoneList = useMemo(() => {
const zones = Object.keys(TIMEZONES.zones).sort();
const browserTz = resolveTimezone(browserDefaultTimeZone);
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(browserDefaultTimeZone)
);
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(browserDefaultTimeZone));
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);
// State to hold the master event when editing "all events"
const [masterEvent, setMasterEvent] = useState<CalendarEvent | null>(null);
const [isLoadingMasterEvent, setIsLoadingMasterEvent] = useState(false);
// Fetch master event when editing "all events" of a recurring series
useEffect(() => {
if (!event || !open || typeOfAction !== "all") {
setMasterEvent(null);
return;
}
const isRecurringEvent = !!event.repetition?.freq;
if (!isRecurringEvent) {
setMasterEvent(null);
return;
}
// Check if this is an instance (has recurrence-id)
const [baseUID, recurrenceId] = event.uid.split("/");
if (!recurrenceId) {
// This is already the master event
setMasterEvent(event);
return;
}
// Fetch the master event
const fetchMasterEvent = async () => {
setIsLoadingMasterEvent(true);
try {
const masterEventToFetch = {
...event,
uid: baseUID, // Use base UID to get master event
};
const fetchedMasterEvent = await getEvent(masterEventToFetch, true);
setMasterEvent(fetchedMasterEvent);
} catch (err) {
console.error("Failed to fetch master event:", err);
// Fallback to using the clicked instance
setMasterEvent(event);
} finally {
setIsLoadingMasterEvent(false);
}
};
fetchMasterEvent();
}, [event, open, typeOfAction]);
// Initialize form state when event data is available
useEffect(() => {
// Skip if restoring from error - data already restored
if (isRestoringFromErrorRef.current) {
return;
}
// Skip if still loading master event
if (isLoadingMasterEvent) {
return;
}
if (event && open) {
// Use master event for "all" action, otherwise use the clicked event
const eventToDisplay =
typeOfAction === "all" && masterEvent ? masterEvent : event;
// Reset validation errors when modal opens
setShowValidationErrors(false);
// Editing existing event - populate fields with event data
setTitle(eventToDisplay.title ?? "");
setDescription(eventToDisplay.description ?? "");
setLocation(eventToDisplay.location ?? "");
// Handle all-day events properly
const isAllDay = eventToDisplay.allday ?? false;
setAllDay(isAllDay);
// Get event's original timezone
const eventTimezone = eventToDisplay.timezone
? resolveTimezone(eventToDisplay.timezone)
: resolveTimezone(browserDefaultTimeZone);
// Format dates based on all-day status
if (eventToDisplay.start) {
if (isAllDay) {
// For all-day events, use date format (YYYY-MM-DD)
const startDate = new Date(eventToDisplay.start);
setStart(startDate.toISOString().split("T")[0]);
} else {
// For timed events, format in the event's original timezone
setStart(
formatDateTimeInTimezone(eventToDisplay.start, eventTimezone)
);
}
} else {
setStart("");
}
if (eventToDisplay.end) {
if (isAllDay) {
// For all-day events, use date format (YYYY-MM-DD)
const endDate = new Date(eventToDisplay.end);
endDate.setDate(endDate.getDate() - 1);
setEnd(endDate.toISOString().split("T")[0]);
} else {
// For timed events, format in the event's original timezone
setEnd(formatDateTimeInTimezone(eventToDisplay.end, eventTimezone));
}
} else {
setEnd("");
}
// Find correct calendar index
setCalendarid(calId);
// Handle repetition properly - check both current event and base event
const baseEventId = extractEventBaseUuid(eventToDisplay.uid);
const baseEvent = calList[calId]?.events[baseEventId];
const repetitionSource =
eventToDisplay.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(
eventToDisplay.attendee
? eventToDisplay.attendee.filter(
(a: userAttendee) =>
a.cal_address !== eventToDisplay.organizer?.cal_address
)
: []
);
setAlarm(eventToDisplay.alarm?.trigger ?? "");
setEventClass(eventToDisplay.class ?? "PUBLIC");
setBusy(eventToDisplay.transp ?? "OPAQUE");
if (eventToDisplay.timezone) {
const resolvedTimezone = resolveTimezone(eventToDisplay.timezone);
setTimezone(resolvedTimezone);
} else {
const browserTz = resolveTimezone(browserDefaultTimeZone);
setTimezone(browserTz);
}
setHasVideoConference(
eventToDisplay.x_openpass_videoconference ? true : false
);
setMeetingLink(eventToDisplay.x_openpass_videoconference || null);
setNewCalId(eventToDisplay.calId || calId);
// Update description to include video conference footer if exists
if (
eventToDisplay.x_openpass_videoconference &&
eventToDisplay.description
) {
const hasVideoFooter = eventToDisplay.description.includes("Visio:");
if (!hasVideoFooter) {
setDescription(
addVideoConferenceToDescription(
eventToDisplay.description,
eventToDisplay.x_openpass_videoconference
)
);
} else {
setDescription(eventToDisplay.description);
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
open,
event,
calId,
userPersonalCalendars,
calList,
masterEvent,
isLoadingMasterEvent,
]);
// 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();
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 && extractEventBaseUuid(eventId) === baseUID) {
instances[eventId] = { ...instance };
}
});
return instances;
};
// When editing "all events" of a recurring series, use the master event we already fetched
let masterEventData: CalendarEvent | null = null;
if (isRecurringEvent && typeOfAction === "all") {
// We already have the master event from state
masterEventData = masterEvent;
if (!masterEventData) {
// This shouldn't happen, but handle it gracefully
const formDataToSave = saveCurrentFormData();
const errorFormData = {
...formDataToSave,
fromError: true,
};
saveEventFormDataToTemp("update", errorFormData);
showErrorNotification(
"Failed to load master 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 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 and end date is same as start date
const startDateOnly = (start || "").split("T")[0];
const endDateOnly = (end || "").split("T")[0];
if (!showMore && !hasEndDateChanged && startDateOnly === endDateOnly) {
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 or end date explicitly shown in normal mode or end date differs from start date: 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,
sequence: (event.sequence ?? 1) + 1,
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 = extractEventBaseUuid(event.uid);
// 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) => extractEventBaseUuid(eventId) === 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) {
// Check if error is an object with response or message properties
const errorObj = deleteError as {
response?: { status?: number };
message?: string;
};
// Silently ignore 404 - file might already be deleted
const is404 =
errorObj.response?.status === 404 ||
errorObj.message?.includes("404") ||
errorObj.message?.includes("Not Found");
if (!is404) {
console.error(
`Failed to delete event file: ${errorObj.message || "Unknown error"}`
);
}
}
});
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`,
sequence: 1, // New event with new UID starts at sequence 1
recurrenceId: undefined,
};
// STEP 3: Persist new event to server
await putEvent(finalNewEvent, targetCalendar.owner?.emails?.[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));
// 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();
initializedKeyRef.current = null;
} catch (err) {
// Check if error is an object with a message property
const errorObj = err as { message?: string };
// API failed - restore form data and mark as error
const errorFormData = {
...formDataToSave,
fromError: true,
};
saveEventFormDataToTemp("update", errorFormData);
// Show error notification
showErrorNotification(
errorObj.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
dispatch(
updateEventLocal({
calId,
event: { ...newEvent, recurrenceId },
})
);
const result = await dispatch(
updateEventInstanceAsync({
cal: targetCalendar,
event: { ...newEvent, recurrenceId },
})
);
// Handle result of updateEventInstanceAsync
await assertThunkSuccess(result);
// Clear temp data on successful save
clearEventFormTempData("update");
} else if (typeOfAction === "all") {
// Update all instances - check if repetition rules changed
const baseUID = extractEventBaseUuid(event.uid);
const changes = detectRecurringEventChanges(
event,
{ repetition, timezone, allday, start, end },
masterEventData,
resolveTimezone
);
const repetitionRulesChanged = changes.repetitionRulesChanged;
if (repetitionRulesChanged) {
// Date/time or repetition rules changed - remove all overrides
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
// IMPORTANT: Use base event UID (master), not instance UID with recurrence-id
const masterEventForUpdate = {
...newEvent,
uid: baseUID, // Use base UID for updating the master
recurrenceId: undefined, // Don't send recurrence-id for master update
};
const result = await dispatch(
updateSeriesAsync({
cal: targetCalendar,
event: masterEventForUpdate,
removeOverrides: true,
})
);
// Handle result of updateSeriesAsync
await assertThunkSuccess(result);
// Clear cache after successful update
dispatch(clearFetchCache(calId));
// Clear temp data on successful save
clearEventFormTempData("update");
} catch (seriesError) {
// Restore instances on error
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
// IMPORTANT: Use base event UID (master), not instance UID with recurrence-id
const masterEventForUpdate = {
...newEvent,
uid: baseUID, // Use base UID for updating the master
recurrenceId: undefined, // Don't send recurrence-id for master update
};
const result = await dispatch(
updateSeriesAsync({
cal: targetCalendar,
event: masterEventForUpdate,
removeOverrides: false,
})
);
// Handle result of updateSeriesAsync
await assertThunkSuccess(result);
// 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
await assertThunkSuccess(result);
// 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 if (newCalId === calId) {
// Normal non-recurring event update (same calendar)
const result = await dispatch(
putEventAsync({ cal: targetCalendar, newEvent })
);
await assertThunkSuccess(result);
// Clear temp data on successful save
clearEventFormTempData("update");
}
// Note: when newCalId !== calId, the move is handled below in the
// "Handle calendar change" block, so we intentionally skip putEventAsync here.
}
// Handle calendar change (move to a different calendar)
if (newCalId !== calId) {
await moveEventBetweenCalendars({
dispatch,
calList,
newEvent,
oldCalId: calId,
newCalId,
});
// Clear temp data on successful move
clearEventFormTempData("update");
}
// Reset all state to default values only on successful save (after all branches)
clearEventFormTempData("update");
resetAllStateToDefault();
initializedKeyRef.current = null;
} catch (error) {
// Check if error is an object with a message property
const errorObj = error as { message?: string };
// 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(
errorObj.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}
eventId={event.uid}
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;