refactor recurring event modification (#173)

This commit is contained in:
Camille Moussu
2025-10-07 18:16:00 +02:00
committed by GitHub
parent 3f8f3eff23
commit c86db8fa49
15 changed files with 1924 additions and 176 deletions
+38
View File
@@ -14,10 +14,13 @@ import { getOpenPaasUser, getUserDetails } from "../User/userAPI";
import { parseCalendarEvent } from "../Events/eventUtils";
import {
deleteEvent,
deleteEventInstance,
getEvent,
importEventFromFile,
moveEvent,
putEvent,
putEventWithOverrides,
updateSeries,
} from "../Events/EventApi";
import {
computeWeekRange,
@@ -274,6 +277,29 @@ export const deleteEventAsync = createAsyncThunk<
return { calId, eventId };
});
export const deleteEventInstanceAsync = createAsyncThunk<
{ calId: string; eventId: string },
{ cal: Calendars; event: CalendarEvent }
>("calendars/delEventInstance", async ({ cal, event }) => {
await deleteEventInstance(event, cal.ownerEmails?.[0]);
return { calId: cal.id, eventId: event.uid };
});
export const updateEventInstanceAsync = createAsyncThunk<
{ calId: string; event: CalendarEvent },
{ cal: Calendars; event: CalendarEvent }
>("calendars/updateEventInstance", async ({ cal, event }) => {
await putEventWithOverrides(event, cal.ownerEmails?.[0]);
return { calId: cal.id, event };
});
export const updateSeriesAsync = createAsyncThunk<
void,
{ cal: Calendars; event: CalendarEvent }
>("calendars/updateSeries", async ({ cal, event }) => {
await updateSeries(event, cal.ownerEmails?.[0]);
});
export const createCalendarAsync = createAsyncThunk<
{
userId: string;
@@ -545,6 +571,18 @@ const CalendarSlice = createSlice({
];
}
})
.addCase(deleteEventInstanceAsync.fulfilled, (state, action) => {
state.pending = false;
delete state.list[action.payload.calId].events[action.payload.eventId];
})
.addCase(updateEventInstanceAsync.fulfilled, (state, action) => {
state.pending = false;
state.list[action.payload.calId].events[action.payload.event.uid] =
action.payload.event;
})
.addCase(updateSeriesAsync.fulfilled, (state) => {
state.pending = false;
})
.addCase(createCalendarAsync.fulfilled, (state, action) => {
state.pending = false;
state.list[`${action.payload.userId}/${action.payload.calId}`] = {
+112 -2
View File
@@ -1,9 +1,15 @@
import { api } from "../../utils/apiUtils";
import { TIMEZONES } from "../../utils/timezone-data";
import { CalendarEvent } from "./EventsTypes";
import { calendarEventToJCal, parseCalendarEvent } from "./eventUtils";
import {
calendarEventToJCal,
makeTimezone,
makeVevent,
parseCalendarEvent,
} from "./eventUtils";
import ICAL from "ical.js";
export async function getEvent(event: CalendarEvent) {
export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
const response = await api.get(`dav${event.URL}`);
const eventData = await response.text();
const eventical = ICAL.parse(eventData);
@@ -13,6 +19,9 @@ export async function getEvent(event: CalendarEvent) {
event.calId,
event.URL
);
if (isMaster) {
return { ...event, ...eventjson };
}
return { ...eventjson, ...event };
}
@@ -37,6 +46,107 @@ export async function putEvent(event: CalendarEvent, calOwnerEmail?: string) {
return response;
}
export async function putEventWithOverrides(
updatedEvent: CalendarEvent,
calOwnerEmail?: string
) {
const vevents = await getAllRecurrentEvent(updatedEvent);
const updatedVevent = makeVevent(
updatedEvent,
updatedEvent.timezone,
calOwnerEmail,
!updatedEvent.recurrenceId
);
let replaced = false;
for (let i = 0; i < vevents.length; i++) {
const ve = vevents[i];
const recurrenceId = ve[1].find(([k]: string[]) => k === "recurrence-id");
if (recurrenceId && recurrenceId[3] === updatedEvent.recurrenceId) {
vevents[i] = updatedVevent; // replace
replaced = true;
break;
}
}
if (!replaced && updatedEvent.recurrenceId) {
vevents.push(updatedVevent); // add new override
}
const timezoneData = TIMEZONES.zones[updatedEvent.timezone];
const vtimezone = makeTimezone(timezoneData, updatedEvent);
const newJCal = ["vcalendar", [], [...vevents, vtimezone.component.jCal]];
return api(`dav${updatedEvent.URL}`, {
method: "PUT",
body: JSON.stringify(newJCal),
headers: {
"content-type": "text/calendar; charset=utf-8",
},
});
}
export const deleteEventInstance = async (
event: CalendarEvent,
calOwnerEmail?: string
) => {
const seriesEvent = await getEvent(
{
...event,
uid: event.uid.split("/")[0],
},
true
);
seriesEvent.exdates = [...(seriesEvent.exdates || []), event.start];
delete seriesEvent.recurrenceId;
return putEvent(seriesEvent, calOwnerEmail);
};
export const updateSeries = async (
event: CalendarEvent,
calOwnerEmail?: string
) => {
const vevents = await getAllRecurrentEvent(event);
const masterIndex = vevents.findIndex(
([, props]: [string, string[]]) =>
!props.find(([k]) => k.toLowerCase() === "recurrence-id")
);
if (masterIndex === -1) {
throw new Error("No master VEVENT found for this series");
}
const rrule = vevents[0][1].find(([k]: string[]) => k === "rrule");
const tzid = event.timezone;
const updatedMaster = makeVevent(event, tzid, calOwnerEmail, true);
const newRrule = updatedMaster[1].find(([k]: string[]) => k === "rrule");
if (!newRrule) {
updatedMaster[1].push(rrule);
}
vevents[masterIndex] = updatedMaster;
const timezoneData = TIMEZONES.zones[event.timezone];
const vtimezone = makeTimezone(timezoneData, event);
const newJCal = ["vcalendar", [], [...vevents, vtimezone.component.jCal]];
return api(`dav${event.URL}`, {
method: "PUT",
body: JSON.stringify(newJCal),
headers: {
"content-type": "text/calendar; charset=utf-8",
},
});
};
async function getAllRecurrentEvent(event: CalendarEvent) {
const response = await api.get(`dav${event.URL}`);
const eventData = await response.text();
const jcal = ICAL.parse(eventData);
const vevents = jcal[2].filter(([name]: string[]) => name === "vevent");
return vevents;
}
export async function moveEvent(event: CalendarEvent, newUrl: string) {
const response = await api(`dav${event.URL}`, {
method: "MOVE",
+116 -51
View File
@@ -3,14 +3,6 @@ import CloseIcon from "@mui/icons-material/Close";
import DeleteIcon from "@mui/icons-material/Delete";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import VideocamIcon from "@mui/icons-material/Videocam";
import {
deleteEventAsync,
moveEventAsync,
putEventAsync,
removeEvent,
} from "../Calendars/CalendarSlice";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import AttendeeSelector from "../../components/Attendees/AttendeeSearch";
import {
Box,
Button,
@@ -33,24 +25,42 @@ import {
Typography,
} from "@mui/material";
import { useEffect, useState } from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import AttendeeSelector from "../../components/Attendees/AttendeeSearch";
import {
handleDelete,
handleRSVP,
} from "../../components/Event/eventHandlers/eventHandlers";
import RepeatEvent from "../../components/Event/EventRepeat";
import { InfoRow } from "../../components/Event/InfoRow";
import { userAttendee } from "../User/userDataTypes";
import { refreshCalendars } from "../../components/Event/utils/eventUtils";
import { renderAttendeeBadge } from "../../components/Event/utils/eventUtils";
import { getCalendarRange } from "../../utils/dateUtils";
import {
moveEventAsync,
putEventAsync,
removeEvent,
updateEventInstanceAsync,
updateSeriesAsync,
} from "../Calendars/CalendarSlice";
import { Calendars } from "../Calendars/CalendarTypes";
import { userAttendee } from "../User/userDataTypes";
import { getEvent } from "./EventApi";
import { formatLocalDateTime } from "./EventModal";
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { renderAttendeeBadge } from "../../components/Event/utils/eventUtils";
export default function EventDisplayModal({
eventId,
calId,
open,
onClose,
typeOfAction,
}: {
eventId: string;
calId: string;
open: boolean;
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
typeOfAction?: "solo" | "all";
}) {
const dispatch = useAppDispatch();
const calendar = useAppSelector((state) => state.calendars.list[calId]);
@@ -87,7 +97,7 @@ export default function EventDisplayModal({
const [alarm, setAlarm] = useState(event?.alarm?.trigger ?? "");
const [busy, setBusy] = useState(event?.transp ?? "OPAQUE");
const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC");
const [timezone] = useState(event?.timezone ?? "UTC");
const [timezone, setTimezone] = useState(event?.timezone ?? "UTC");
const [newCalId, setNewCalId] = useState(event?.calId);
const [calendarid, setCalendarid] = useState(
calId.split("/")[0] === user.userData?.openpaasId
@@ -119,19 +129,29 @@ export default function EventDisplayModal({
}
setRepetition(event?.repetition ?? ({} as RepetitionObject));
}, [open, eventId, dispatch, onClose, event]);
useEffect(() => {
const fetchMasterEvent = async () => {
const masterEvent = await getEvent(event);
setTitle(masterEvent.title ?? "");
setDescription(masterEvent.description ?? "");
setLocation(masterEvent.location ?? "");
setStart(formatLocalDateTime(new Date(masterEvent?.start ?? Date.now())));
setEnd(formatLocalDateTime(new Date(masterEvent?.end ?? Date.now())));
setAllDay(masterEvent.allday ?? false);
setRepetition(masterEvent?.repetition ?? ({} as RepetitionObject));
setAlarm(masterEvent?.alarm?.trigger ?? "");
setBusy(masterEvent?.transp ?? "OPAQUE");
setEventClass(masterEvent?.class ?? "PUBLIC");
setTimezone(masterEvent.timezone ?? "UTC");
};
if (typeOfAction === "all") {
fetchMasterEvent();
}
}, [typeOfAction, event]);
if (!event || !calendar) return null;
function handleRSVP(rsvp: string) {
const newEvent = {
...event,
attendee: event.attendee?.map((a) =>
a.cal_address === user.userData.email ? { ...a, partstat: rsvp } : a
),
};
dispatch(putEventAsync({ cal: calendar, newEvent }));
}
const isRecurring = event.uid?.includes("/");
const handleSave = async () => {
const newEventUID = crypto.randomUUID();
@@ -157,28 +177,35 @@ export default function EventDisplayModal({
};
const [baseId, recurrenceId] = event.uid.split("/");
if (recurrenceId) {
Object.keys(userPersonnalCalendars[calendarid].events).forEach(
(element) => {
if (element.split("/")[0] === baseId) {
dispatch(removeEvent({ calendarUid: calId, eventUid: element }));
}
}
const calendarRange = getCalendarRange(new Date(start));
if (typeOfAction === "solo") {
dispatch(
updateEventInstanceAsync({
cal: userPersonnalCalendars[calendarid],
event: { ...newEvent, recurrenceId: recurrenceId },
})
);
} else if (typeOfAction === "all") {
dispatch(
updateSeriesAsync({
cal: userPersonnalCalendars[calendarid],
event: { ...newEvent, recurrenceId: recurrenceId },
})
);
await refreshCalendars(dispatch, calendars, calendarRange);
} else {
dispatch(
putEventAsync({ cal: userPersonnalCalendars[calendarid], newEvent })
);
}
await dispatch(
putEventAsync({
cal: userPersonnalCalendars[calendarid],
newEvent,
})
);
if (newCalId !== calId) {
dispatch(
moveEventAsync({
cal: userPersonnalCalendars[calendarid],
newEvent,
newURL: `/calendars/${newCalId}/${event.uid}.ics`,
newURL: `/calendars/${newCalId}/${baseId}.ics`,
})
);
dispatch(removeEvent({ calendarUid: calId, eventUid: event.uid }));
@@ -268,7 +295,17 @@ export default function EventDisplayModal({
? "success"
: "primary"
}
onClick={() => handleRSVP("ACCEPTED")}
onClick={() =>
handleRSVP(
dispatch,
calendar,
user,
event,
"ACCEPTED",
undefined,
isRecurring ? typeOfAction : undefined
)
}
>
Accept
</Button>
@@ -278,7 +315,17 @@ export default function EventDisplayModal({
? "warning"
: "primary"
}
onClick={() => handleRSVP("TENTATIVE")}
onClick={() =>
handleRSVP(
dispatch,
calendar,
user,
event,
"TENTATIVE",
undefined,
isRecurring ? typeOfAction : undefined
)
}
>
Maybe
</Button>
@@ -288,7 +335,17 @@ export default function EventDisplayModal({
? "error"
: "primary"
}
onClick={() => handleRSVP("DECLINED")}
onClick={() =>
handleRSVP(
dispatch,
calendar,
user,
event,
"DECLINED",
undefined,
isRecurring ? typeOfAction : undefined
)
}
>
Decline
</Button>
@@ -463,12 +520,14 @@ export default function EventDisplayModal({
{/* Extended options */}
{showMore && (
<>
<RepeatEvent
repetition={repetition}
eventStart={event.start}
setRepetition={setRepetition}
isOwn={isOwn}
/>
{isOwn && (
<RepeatEvent
repetition={repetition}
eventStart={event.start}
setRepetition={setRepetition}
isOwn={isOwn && typeOfAction !== "solo"}
/>
)}
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="notification">Notification</InputLabel>
<Select
@@ -546,12 +605,18 @@ export default function EventDisplayModal({
{isOwn && (
<IconButton
size="small"
onClick={() => {
onClose({}, "backdropClick");
dispatch(
deleteEventAsync({ calId, eventId, eventURL: event.URL })
);
}}
onClick={() =>
handleDelete(
isRecurring,
typeOfAction,
onClose,
dispatch,
calendar,
event,
calId,
eventId
)
}
>
<DeleteIcon fontSize="small" />
</IconButton>
+2
View File
@@ -4,6 +4,7 @@ export interface CalendarEvent {
URL: string;
calId: string;
uid: string;
recurrenceId?: string;
transp?: string;
start: string; // ISO date string
end?: string;
@@ -23,6 +24,7 @@ export interface CalendarEvent {
timezone: string;
repetition?: RepetitionObject;
alarm?: AlarmObject;
exdates?: string[];
}
export interface RepetitionObject {
+49 -11
View File
@@ -79,6 +79,10 @@ export function parseCalendarEvent(
case "recurrence-id":
recurrenceId = value;
break;
case "exdate":
if (!event.exdates) event.exdates = [];
event.exdates.push(value);
break;
case "status":
event.status = String(value);
break;
@@ -101,6 +105,7 @@ export function parseCalendarEvent(
}
if (recurrenceId && event.uid) {
event.uid = `${event.uid}/${recurrenceId}`;
event.recurrenceId = recurrenceId;
}
if (valarm) {
@@ -135,6 +140,36 @@ export function calendarEventToJCal(
): any[] {
const tzid = event.timezone; // Fallback to UTC if no timezone provided
const vevent: any[] = makeVevent(event, tzid, calOwnerEmail);
const timezoneData = TIMEZONES.zones[event.timezone];
const vtimezone = makeTimezone(timezoneData, event);
return ["vcalendar", [], [vevent, vtimezone.component.jCal]];
}
export function makeTimezone(
timezoneData: { ics: string; latitude: string; longitude: string },
event: CalendarEvent
) {
if (!timezoneData) {
return new ICAL.Timezone({
component: TIMEZONES.zones["Etc/UTC"].ics,
tzid: "Etc/UTC",
});
}
return new ICAL.Timezone({
component: timezoneData.ics,
tzid: event.timezone,
});
}
export function makeVevent(
event: CalendarEvent,
tzid: string,
calOwnerEmail: string | undefined,
isMasterEvent?: boolean
) {
const vevent: any[] = [
"vevent",
[
@@ -154,6 +189,7 @@ export function calendarEventToJCal(
event.x_openpass_videoconference ?? null,
],
["summary", {}, "text", event.title ?? ""],
["dstamp", { tzid }, "date-time", formatDateToICal(new Date(), false)],
],
];
if (event.alarm?.trigger) {
@@ -201,6 +237,9 @@ export function calendarEventToJCal(
if (event.location) {
vevent[1].push(["location", {}, "text", event.location]);
}
if (event.recurrenceId && !isMasterEvent) {
vevent[1].push(["recurrence-id", {}, "date-time", event.recurrenceId]);
}
if (event.description) {
vevent[1].push(["description", {}, "text", event.description]);
}
@@ -242,21 +281,20 @@ export function calendarEventToJCal(
]);
});
const timezoneData = TIMEZONES.zones[event.timezone];
if (!timezoneData) {
const vtimezone = new ICAL.Timezone({
component: TIMEZONES.zones["Etc/UTC"].ics,
tzid: "Etc/UTC",
if (event.exdates && event.exdates.length > 0) {
event.exdates.forEach((ex) => {
vevent[1].push([
"exdate",
{ tzid },
"date-time",
formatDateToICal(new Date(ex), false),
]);
});
return ["vcalendar", [], [vevent, vtimezone.component.jCal]];
}
const vtimezone = new ICAL.Timezone({
component: timezoneData.ics,
tzid: event.timezone,
});
return ["vcalendar", [], [vevent, vtimezone.component.jCal]];
return vevent;
}
function formatDateToICal(date: Date, allday: Boolean) {
// Format date like: 2025-02-14T11:00:00 (local time)