Files
workavia-calendar-front/src/features/Events/EventUpdateModal.tsx
T
lenhanphung 42c953ccf9 feat(events): align Update modal with Create; extract shared form fields
- ux: close Update modal immediately on Save; run API in background
- fix: remove stale single-instance when converting to repeating
- test: adjust EventDisplay expectations
- refactor: share form via components/Event/EventFormFields (used by Create/Update)
2025-10-15 08:29:28 +02:00

450 lines
14 KiB
TypeScript

import { Box, Button } from "@mui/material";
import React, {
useEffect,
useState,
useMemo,
useCallback,
useRef,
} from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { ResponsiveDialog } from "../../components/Dialog";
import {
putEventAsync,
removeEvent,
moveEventAsync,
} 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, {
formatLocalDateTime,
} from "../../components/Event/EventFormFields";
import { getEvent } from "./EventApi";
function EventUpdateModal({
eventId,
calId,
open,
onClose,
eventData,
}: {
eventId: string;
calId: string;
open: boolean;
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
eventData?: CalendarEvent | null;
}) {
const dispatch = useAppDispatch();
// 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 userPersonnalCalendars: Calendars[] = useMemo(() => {
const allCalendars = Object.values(calendarsList);
return allCalendars.filter(
(c) => 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 [calendarid, setCalendarid] = useState(0);
const [attendees, setAttendees] = useState<userAttendee[]>([]);
const [hasVideoConference, setHasVideoConference] = useState(false);
const [meetingLink, setMeetingLink] = useState<string | null>(null);
const [important, setImportant] = useState(false);
const resetAllStateToDefault = useCallback(() => {
setShowMore(false);
setShowDescription(false);
setShowRepeat(false);
setTitle("");
setDescription("");
setAttendees([]);
setLocation("");
setStart("");
setEnd("");
setCalendarid(0);
setAllDay(false);
setRepetition({} as RepetitionObject);
setAlarm("");
setEventClass("PUBLIC");
setBusy("OPAQUE");
setImportant(false);
setTimezone(
resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone)
);
setHasVideoConference(false);
setMeetingLink(null);
}, []);
// Prevent repeated initialization loops
const initializedKeyRef = useRef<string | null>(null);
// Initialize form state when event data is available
useEffect(() => {
if (event && open) {
// 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);
// Format dates based on all-day status
if (event.start) {
const startDate = new Date(event.start);
if (isAllDay) {
// For all-day events, use date format (YYYY-MM-DD)
setStart(startDate.toISOString().split("T")[0]);
} else {
// For timed events, use datetime format
setStart(formatLocalDateTime(startDate));
}
} else {
setStart("");
}
if (event.end) {
const endDate = new Date(event.end);
if (isAllDay) {
// For all-day events, use date format (YYYY-MM-DD)
setEnd(endDate.toISOString().split("T")[0]);
} else {
// For timed events, use datetime format
setEnd(formatLocalDateTime(endDate));
}
} else {
setEnd("");
}
// Find correct calendar index
const currentCalIndex = userPersonnalCalendars.findIndex(
(cal) => cal.id === calId
);
setCalendarid(currentCalIndex >= 0 ? currentCalIndex : 0);
// 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) => a.cal_address !== event.organizer?.cal_address
)
: []
);
setAlarm(event.alarm?.trigger ?? "");
setEventClass(event.class ?? "PUBLIC");
setBusy(event.transp ?? "OPAQUE");
const resolvedTimezone = event.timezone
? resolveTimezone(event.timezone)
: resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
setTimezone(resolvedTimezone);
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, userPersonnalCalendars, calendarsList]);
const handleClose = () => {
onClose({}, "backdropClick");
resetAllStateToDefault();
initializedKeyRef.current = null;
};
const handleSave = async () => {
if (!event) return;
const organizer = event.organizer;
const targetCalendar = userPersonnalCalendars[calendarid];
if (!targetCalendar) {
console.error("Target calendar not found");
return;
}
// Handle start and end dates based on all-day status
let startDate: string;
let endDate: string;
if (allday) {
// For all-day events, use date format (YYYY-MM-DD)
startDate = new Date(start).toISOString().split("T")[0];
endDate = new Date(end).toISOString().split("T")[0];
} else {
// For timed events, use full datetime
startDate = new Date(start).toISOString();
endDate = new Date(end).toISOString();
}
const newEvent: CalendarEvent = {
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,
attendee: organizer
? [organizer as userAttendee, ...attendees]
: attendees,
transp: busy,
color: targetCalendar?.color,
alarm: { trigger: alarm, action: "EMAIL" },
x_openpass_videoconference: meetingLink || undefined,
};
// Close popup immediately for better UX
onClose({}, "backdropClick");
// If converting from a non-repeating event to a repeating one,
// remove the original single instance to avoid duplicates on the grid
if (!event.repetition?.freq && repetition?.freq) {
dispatch(removeEvent({ calendarUid: calId, eventUid: event.uid }));
}
// Handle recurrence instances
const [baseId, recurrenceId] = event.uid.split("/");
if (recurrenceId) {
Object.keys(targetCalendar.events).forEach((element) => {
if (element.split("/")[0] === baseId) {
dispatch(removeEvent({ calendarUid: calId, eventUid: element }));
}
});
}
// Execute API calls in background
dispatch(
putEventAsync({
cal: targetCalendar,
newEvent,
})
);
// Handle calendar change
if (newCalId !== calId) {
dispatch(
moveEventAsync({
cal: targetCalendar,
newEvent,
newURL: `/calendars/${newCalId}/${event.uid}.ics`,
})
);
dispatch(removeEvent({ calendarUid: calId, eventUid: event.uid }));
}
};
const dialogActions = (
<Box display="flex" justifyContent="space-between" width="100%" px={2}>
{!showMore && (
<Button onClick={() => setShowMore(!showMore)}>Show More</Button>
)}
<Box display="flex" gap={1} ml={showMore ? "auto" : 0}>
<Button variant="outlined" onClick={handleClose}>
Cancel
</Button>
<Button variant="contained" onClick={handleSave} disabled={!title}>
Save
</Button>
</Box>
</Box>
);
if (!event) return null;
return (
<ResponsiveDialog
open={open}
onClose={handleClose}
title="Update Event"
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}
attendees={attendees}
setAttendees={setAttendees}
alarm={alarm}
setAlarm={setAlarm}
busy={busy}
setBusy={setBusy}
eventClass={eventClass}
setEventClass={setEventClass}
timezone={timezone}
setTimezone={setTimezone}
calendarid={calendarid}
setCalendarid={setCalendarid}
important={important}
setImportant={setImportant}
hasVideoConference={hasVideoConference}
setHasVideoConference={setHasVideoConference}
meetingLink={meetingLink}
setMeetingLink={setMeetingLink}
showMore={showMore}
showDescription={showDescription}
setShowDescription={setShowDescription}
showRepeat={showRepeat}
setShowRepeat={setShowRepeat}
userPersonnalCalendars={userPersonnalCalendars}
timezoneList={timezoneList}
onCalendarChange={(newCalendarId) => {
const selectedCalendar = userPersonnalCalendars[newCalendarId];
if (selectedCalendar) {
setNewCalId(selectedCalendar.id);
}
}}
/>
</ResponsiveDialog>
);
}
export default EventUpdateModal;