1097 lines
36 KiB
TypeScript
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;
|