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
+60 -65
View File
@@ -46,7 +46,7 @@ describe("Event Preview Display", () => {
color: "#FF0000",
events: {
event1: {
id: "event1",
uid: "event1",
title: "Test Event",
calId: "667037022b752d0026472254/cal1",
start: day.toISOString(),
@@ -72,7 +72,7 @@ describe("Event Preview Display", () => {
],
},
event2: {
id: "event2",
uid: "event2",
title: "Test Event",
calId: "667037022b752d0026472254/cal1",
start: day.toISOString(),
@@ -87,7 +87,7 @@ describe("Event Preview Display", () => {
color: "#FF0000",
events: {
event1: {
id: "event1",
uid: "event1",
calId: "otherCal/cal",
title: "Test Event Other cal",
start: day.toISOString(),
@@ -358,7 +358,6 @@ describe("Event Preview Display", () => {
await waitFor(() => {
expect(spy).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
const updatedEvent = spy.mock.calls[0][0].newEvent;
@@ -416,7 +415,6 @@ describe("Event Preview Display", () => {
await waitFor(() => {
expect(spy).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
const updatedEvent = spy.mock.calls[0][0].newEvent;
@@ -474,7 +472,6 @@ describe("Event Preview Display", () => {
await waitFor(() => {
expect(spy).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
const updatedEvent = spy.mock.calls[0][0].newEvent;
@@ -507,8 +504,8 @@ describe("Event Preview Display", () => {
await waitFor(() => {
expect(spy).toHaveBeenCalled();
expect(screen.getByText("Edit Event")).toBeInTheDocument();
});
expect(screen.getByText("Edit Event")).toBeInTheDocument();
});
it("properly render message button when MAIL_SPA_URL is not null and event has attendees", () => {
(window as any).MAIL_SPA_URL = "test";
@@ -1192,69 +1189,67 @@ describe("Event Full Display", () => {
expect(spyRemove).toHaveBeenCalled();
});
// const spyPut = jest
// .spyOn(eventThunks, "putEventAsync")
// .mockImplementation((payload) => () => Promise.resolve(payload) as any);
// const spyRemove = jest.spyOn(eventThunks, "removeEvent");
it("removes recurrence instances when saving an edited recurring series", async () => {
const spyPut = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => () => Promise.resolve(payload) as any);
const spyRemove = jest.spyOn(eventThunks, "removeEvent");
// const day = new Date();
// const preloadedRecurrence = {
// ...preloadedState,
// calendars: {
// list: {
// "667037022b752d0026472254/cal1": {
// id: "667037022b752d0026472254/cal1",
// name: "First Calendar",
// color: "#FF0000",
// events: {
// "base/20250101": {
// uid: "base/20250101",
// calId: "667037022b752d0026472254/cal1",
// title: "eventA",
// },
// "base/20250201": {
// uid: "base/20250201",
// calId: "667037022b752d0026472254/cal1",
// title: "eventB",
// },
// "base/20250301": {
// uid: "base/20250301",
// title: "Recurring event",
// calId: "667037022b752d0026472254/cal1",
// start: day.toISOString(),
// end: day.toISOString(),
// organizer: { cal_address: "test@test.com" },
// attendee: [{ cal_address: "test@test.com", cn: "Test" }],
// },
// },
// },
// },
// pending: false,
// },
// };
const day = new Date();
const preloadedRecurrence = {
...preloadedState,
calendars: {
list: {
"667037022b752d0026472254/cal1": {
id: "667037022b752d0026472254/cal1",
name: "First Calendar",
color: "#FF0000",
events: {
"base/20250101": {
uid: "base/20250101",
calId: "667037022b752d0026472254/cal1",
title: "eventA",
},
"base/20250201": {
uid: "base/20250201",
calId: "667037022b752d0026472254/cal1",
title: "eventB",
},
"base/20250301": {
uid: "base/20250301",
title: "Recurring event",
calId: "667037022b752d0026472254/cal1",
start: day.toISOString(),
end: day.toISOString(),
organizer: { cal_address: "test@test.com" },
attendee: [{ cal_address: "test@test.com", cn: "Test" }],
},
},
},
},
pending: false,
},
};
// renderWithProviders(
// <EventDisplayModal
// open={true}
// onClose={mockOnClose}
// calId={"667037022b752d0026472254/cal1"}
// eventId={"base/20250301"}
// />,
// preloadedRecurrence
// );
renderWithProviders(
<EventDisplayModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"base/20250301"}
/>,
preloadedRecurrence
);
// act(() => fireEvent.click(screen.getByText("Save")));
act(() => fireEvent.click(screen.getByText("Save")));
// await waitFor(() => {
// expect(spyPut).toHaveBeenCalled();
// });
await waitFor(() => {
expect(spyPut).toHaveBeenCalled();
});
await waitFor(() => {
expect(spyRemove).toHaveBeenCalled();
});
});
// await waitFor(() => {
// expect(spyRemove).toHaveBeenCalled();
// });
// });
it("InfoRow renders error style when error prop is true", () => {
renderWithProviders(<InfoRow icon={<span>i</span>} text="Bad" error />);
File diff suppressed because it is too large Load Diff
+21
View File
@@ -33,6 +33,7 @@ import {
} from "./utils/calendarUtils";
import { useCalendarEventHandlers } from "./hooks/useCalendarEventHandlers";
import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
import { EditModeDialog } from "../Event/EditModeDialog";
interface CalendarAppProps {
calendarRef: React.RefObject<CalendarApi | null>;
@@ -166,6 +167,14 @@ export default function CalendarApp({
const [eventDisplayedId, setEventDisplayedId] = useState("");
const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false);
const [eventDisplayedCalId, setEventDisplayedCalId] = useState("");
const [openEditModePopup, setOpenEditModePopup] = useState<string | null>(
null
);
const [, setTypeOfAction] = useState<"solo" | "all" | undefined>(undefined);
const [afterChoiceFunc, setAfterChoiceFunc] = useState<Function>();
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent>(
{} as CalendarEvent
);
const [selectedRange, setSelectedRange] = useState<DateSelectArg | null>(
null
);
@@ -188,6 +197,9 @@ export default function CalendarApp({
setEventDisplayedCalId,
setEventDisplayedTemp,
calendars,
setSelectedEvent,
setAfterChoiceFunc,
setOpenEditModePopup,
});
// View handlers
@@ -422,6 +434,15 @@ export default function CalendarApp({
calendarRef={calendarRef}
event={tempEvent}
/>
<EditModeDialog
type={openEditModePopup}
setOpen={setOpenEditModePopup}
event={selectedEvent}
eventAction={(type: "solo" | "all" | undefined) => {
setTypeOfAction(type);
afterChoiceFunc && afterChoiceFunc(type);
}}
/>
{openEventDisplay && eventDisplayedId && eventDisplayedCalId && (
<EventPreviewModal
eventId={eventDisplayedId}
@@ -5,11 +5,14 @@ import { Calendars } from "../../../features/Calendars/CalendarTypes";
import { getDeltaInMilliseconds } from "../../../utils/dateUtils";
import {
getCalendarDetailAsync,
getEventAsync,
putEventAsync,
updateEventInstanceAsync,
updateEventLocal,
updateSeriesAsync,
} from "../../../features/Calendars/CalendarSlice";
import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils";
import { getEvent } from "../../../features/Events/EventApi";
import { refreshCalendars } from "../../Event/utils/eventUtils";
export interface EventHandlersProps {
setSelectedRange: (range: DateSelectArg | null) => void;
@@ -24,6 +27,9 @@ export interface EventHandlersProps {
setEventDisplayedCalId: (id: string) => void;
setEventDisplayedTemp: (temp: boolean) => void;
calendars: Record<string, Calendars>;
setSelectedEvent: (event: CalendarEvent) => void;
setAfterChoiceFunc: (func: Function) => void;
setOpenEditModePopup: (open: string) => void;
}
export const createEventHandlers = (props: EventHandlersProps) => {
@@ -40,6 +46,9 @@ export const createEventHandlers = (props: EventHandlersProps) => {
setEventDisplayedCalId,
setEventDisplayedTemp,
calendars,
setSelectedEvent,
setAfterChoiceFunc,
setOpenEditModePopup,
} = props;
const handleDateSelect = (selectInfo: DateSelectArg) => {
@@ -113,12 +122,6 @@ export const createEventHandlers = (props: EventHandlersProps) => {
};
const handleEventAllow = (dropInfo: any, draggedEvent: any) => {
if (
draggedEvent?.extendedProps.uid &&
draggedEvent.extendedProps.uid.split("/")[1]
) {
return false;
}
return true;
};
@@ -131,7 +134,9 @@ export const createEventHandlers = (props: EventHandlersProps) => {
calendars[arg.event._def.extendedProps.calId].events[
arg.event._def.extendedProps.uid
];
const calendar = calendars[arg.event._def.extendedProps.calId];
const isRecurring = event.uid.includes("/");
const totalDeltaMs = getDeltaInMilliseconds(arg.delta);
const originalStart = new Date(event.start);
@@ -143,8 +148,43 @@ export const createEventHandlers = (props: EventHandlersProps) => {
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
} as CalendarEvent;
dispatch(updateEventLocal({ calId: newEvent.calId, event: newEvent }));
dispatch(putEventAsync({ cal: calendars[newEvent.calId], newEvent }));
if (isRecurring) {
setSelectedEvent(event);
setOpenEditModePopup("edit");
setAfterChoiceFunc(
() => async (typeOfAction: "solo" | "all" | undefined) => {
if (typeOfAction === "solo") {
dispatch(
updateEventInstanceAsync({ cal: calendar, event: newEvent })
);
dispatch(
updateEventLocal({ calId: newEvent.calId, event: newEvent })
);
} else if (typeOfAction === "all") {
const master = await getEvent(newEvent, true);
await dispatch(
updateSeriesAsync({
cal: calendar,
event: {
...master,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
},
})
);
await refreshCalendars(
dispatch,
Object.values(calendars),
calendarRange
);
}
}
);
} else {
dispatch(updateEventLocal({ calId: newEvent.calId, event: newEvent }));
dispatch(putEventAsync({ cal: calendars[newEvent.calId], newEvent }));
}
};
const handleEventResize = (arg: any) => {
@@ -156,9 +196,10 @@ export const createEventHandlers = (props: EventHandlersProps) => {
calendars[arg.event._def.extendedProps.calId].events[
arg.event._def.extendedProps.uid
];
if (event.uid.split("/")[1]) {
dispatch(getEventAsync(event));
}
const calendar = calendars[arg.event._def.extendedProps.calId];
const isRecurring = event.uid.includes("/");
const originalStart = new Date(event.start);
const computedNewStart = new Date(
originalStart.getTime() + getDeltaInMilliseconds(arg.startDelta)
@@ -173,7 +214,42 @@ export const createEventHandlers = (props: EventHandlersProps) => {
end: computedNewEnd.toISOString(),
} as CalendarEvent;
dispatch(putEventAsync({ cal: calendars[newEvent.calId], newEvent }));
if (isRecurring) {
setSelectedEvent(event);
setOpenEditModePopup("edit");
setAfterChoiceFunc(
() => async (typeOfAction: "solo" | "all" | undefined) => {
if (typeOfAction === "solo") {
dispatch(
updateEventInstanceAsync({ cal: calendar, event: newEvent })
);
dispatch(
updateEventLocal({ calId: newEvent.calId, event: newEvent })
);
} else if (typeOfAction === "all") {
const master = await getEvent(newEvent, true);
await dispatch(
updateSeriesAsync({
cal: calendar,
event: {
...master,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
},
})
);
await refreshCalendars(
dispatch,
Object.values(calendars),
calendarRange
);
}
}
);
} else {
dispatch(putEventAsync({ cal: calendars[newEvent.calId], newEvent }));
}
};
return {
@@ -37,6 +37,9 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => {
handleEventDrop: useCallback(eventHandlers.handleEventDrop, [
props.calendars,
props.dispatch,
props.setSelectedEvent,
props.setOpenEditModePopup,
props.setAfterChoiceFunc,
]),
handleEventResize: useCallback(eventHandlers.handleEventResize, [
props.calendars,
+70
View File
@@ -0,0 +1,70 @@
import {
Button,
ButtonGroup,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
Radio,
RadioGroup,
} from "@mui/material";
import { CalendarEvent } from "../../features/Events/EventsTypes";
import { useState } from "react";
export function EditModeDialog({
type,
setOpen,
event,
eventAction,
}: {
type: string | null;
setOpen: (e: string | null) => void;
event: CalendarEvent;
eventAction: (type: "solo" | "all" | undefined) => void;
}) {
const [typeOfAction, setTypeOfAction] = useState<"solo" | "all" | undefined>(
"solo"
);
const handleEvent = async () => {
eventAction(typeOfAction);
handleClose();
};
const handleClose = () => {
setOpen(null);
setTypeOfAction("solo");
};
return (
<Dialog open={Boolean(type)} onClose={handleClose}>
<DialogTitle>
{type === "edit" && "Update the reccurent event"}
{type === "attendance" && "Update the participation status"}
</DialogTitle>
<DialogContent>
<RadioGroup
value={typeOfAction}
onChange={(e) =>
setTypeOfAction(e.target.value as "solo" | "all" | undefined)
}
>
<FormControlLabel
value="solo"
control={<Radio />}
label="This event"
/>
<FormControlLabel
value="all"
control={<Radio />}
label="All the events"
/>
</RadioGroup>
</DialogContent>
<DialogActions>
<ButtonGroup>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleEvent}>Ok</Button>
</ButtonGroup>
</DialogActions>
</Dialog>
);
}
+144 -30
View File
@@ -27,12 +27,13 @@ import { useAppDispatch, useAppSelector } from "../../app/hooks";
import {
deleteEventAsync,
getEventAsync,
putEventAsync,
} from "../../features/Calendars/CalendarSlice";
import { dlEvent } from "../../features/Events/EventApi";
import EventDisplayModal from "../../features/Events/EventDisplay";
import { ResponsiveDialog } from "../Dialog";
import { EditModeDialog } from "./EditModeDialog";
import EventDuplication from "./EventDuplicate";
import { handleDelete, handleRSVP } from "./eventHandlers/eventHandlers";
import { InfoRow } from "./InfoRow";
import { renderAttendeeBadge } from "./utils/eventUtils";
export default function EventPreviewModal({
@@ -50,13 +51,26 @@ export default function EventPreviewModal({
}) {
const dispatch = useAppDispatch();
const calendars = useAppSelector((state) => state.calendars);
const calendarList = Object.values(
useAppSelector((state) => state.calendars.list)
);
const calendar = tempEvent
? calendars.templist[calId]
: calendars.list[calId];
const event = calendar.events[eventId];
const user = useAppSelector((state) => state.user);
const isRecurring = event?.uid?.includes("/");
const [showAllAttendees, setShowAllAttendees] = useState(false);
const [openFullDisplay, setOpenFullDisplay] = useState(false);
const [openEditModePopup, setOpenEditModePopup] = useState<string | null>(
null
);
const [typeOfAction, setTypeOfAction] = useState<"solo" | "all" | undefined>(
undefined
);
const [afterChoiceFunc, setAfterChoiceFunc] = useState<Function>();
const [toggleActionMenu, setToggleActionMenu] = useState<Element | null>(
null
);
@@ -89,18 +103,6 @@ export default function EventPreviewModal({
(a) => a.cal_address === event.organizer?.cal_address
);
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 }));
onClose({}, "backdropClick");
}
return (
<>
<ResponsiveDialog
@@ -137,8 +139,16 @@ export default function EventPreviewModal({
<IconButton
size="small"
onClick={async () => {
setOpenFullDisplay(!openFullDisplay);
await dispatch(getEventAsync(event));
if (isRecurring) {
setAfterChoiceFunc(() => async () => {
await dispatch(getEventAsync(event));
setOpenFullDisplay(!openFullDisplay);
});
setOpenEditModePopup("edit");
} else {
await dispatch(getEventAsync(event));
setOpenFullDisplay(!openFullDisplay);
}
}}
>
<EditIcon />
@@ -173,14 +183,32 @@ export default function EventPreviewModal({
{user.userData.email === event.organizer?.cal_address && (
<MenuItem
onClick={() => {
onClose({}, "backdropClick");
dispatch(
deleteEventAsync({
calId,
eventId,
eventURL: event.URL,
})
);
if (isRecurring) {
setAfterChoiceFunc(
() =>
(typeOfAction?: "solo" | "all" | undefined) =>
handleDelete(
isRecurring,
typeOfAction,
onClose,
dispatch,
calendar,
event,
calId,
eventId
)
);
setOpenEditModePopup("edit");
} else {
onClose({}, "backdropClick");
dispatch(
deleteEventAsync({
calId,
eventId,
eventURL: event.URL,
})
);
}
}}
>
Delete event
@@ -216,10 +244,7 @@ export default function EventPreviewModal({
}
actions={
<>
{currentUserAttendee &&
event.uid?.split("/")[1] &&
"event is a reccuring event, the interraction between acceptance and recurrence is broken and to avoid loosing reccuring events by accepting or refusing them this feature isn't available for now"}
{currentUserAttendee && !event.uid?.split("/")[1] && (
{currentUserAttendee && (
<Box style={{ display: "flex", flexDirection: "row" }}>
<Typography variant="body2">Attending?</Typography>
<ButtonGroup size="small" fullWidth>
@@ -229,7 +254,34 @@ export default function EventPreviewModal({
? "success"
: "primary"
}
onClick={() => handleRSVP("ACCEPTED")}
onClick={() => {
if (isRecurring) {
setAfterChoiceFunc(
() => (type: string) =>
handleRSVP(
dispatch,
calendar,
user,
event,
"ACCEPTED",
onClose,
type,
calendarList
)
);
setOpenEditModePopup("attendance");
} else {
handleRSVP(
dispatch,
calendar,
user,
event,
"ACCEPTED",
onClose
);
}
}}
>
Accept
</Button>
@@ -239,7 +291,33 @@ export default function EventPreviewModal({
? "warning"
: "primary"
}
onClick={() => handleRSVP("TENTATIVE")}
onClick={() => {
if (isRecurring) {
setAfterChoiceFunc(
() => (type: string) =>
handleRSVP(
dispatch,
calendar,
user,
event,
"TENTATIVE",
onClose,
type,
calendarList
)
);
setOpenEditModePopup("attendance");
} else {
handleRSVP(
dispatch,
calendar,
user,
event,
"TENTATIVE",
onClose
);
}
}}
>
Maybe
</Button>
@@ -249,7 +327,33 @@ export default function EventPreviewModal({
? "error"
: "primary"
}
onClick={() => handleRSVP("DECLINED")}
onClick={() => {
if (isRecurring) {
setAfterChoiceFunc(
() => (type: string) =>
handleRSVP(
dispatch,
calendar,
user,
event,
"DECLINED",
onClose,
type,
calendarList
)
);
setOpenEditModePopup("attendance");
} else {
handleRSVP(
dispatch,
calendar,
user,
event,
"DECLINED",
onClose
);
}
}}
>
Decline
</Button>
@@ -387,11 +491,21 @@ export default function EventPreviewModal({
</>
)}
</ResponsiveDialog>
<EditModeDialog
type={openEditModePopup}
setOpen={setOpenEditModePopup}
event={event}
eventAction={(type: "solo" | "all" | undefined) => {
setTypeOfAction(type);
afterChoiceFunc && afterChoiceFunc(type);
}}
/>
<EventDisplayModal
open={openFullDisplay}
onClose={() => setOpenFullDisplay(false)}
eventId={eventId}
calId={calId}
typeOfAction={typeOfAction}
/>
</>
);
+17 -4
View File
@@ -29,7 +29,6 @@ export default function RepeatEvent({
}) {
const days = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
const day = new Date(eventStart);
// derive endOption based on repetition
const getEndOption = () => {
if (repetition.occurrences && repetition.occurrences >= 0) return "after";
@@ -65,6 +64,7 @@ export default function RepeatEvent({
<TextField
type="number"
value={repetition.interval ?? 1}
disabled={!isOwn}
onChange={(e) =>
setRepetition({
...repetition,
@@ -78,6 +78,7 @@ export default function RepeatEvent({
<FormControl size="small" style={{ minWidth: 120 }}>
<Select
value={repetition.freq ?? "daily"}
disabled={!isOwn}
onChange={(e: SelectChangeEvent) => {
if (e.target.value === "weekly") {
setRepetition({
@@ -86,7 +87,11 @@ export default function RepeatEvent({
selectedDays: [days[day.getDay() - 1]],
});
} else {
setRepetition({ ...repetition, freq: e.target.value });
setRepetition({
...repetition,
freq: e.target.value,
selectedDays: undefined,
});
}
}}
>
@@ -108,6 +113,7 @@ export default function RepeatEvent({
{days.map((day) => (
<FormControlLabel
key={day}
disabled={!isOwn}
control={
<Checkbox
checked={repetition.selectedDays?.includes(day) ?? false}
@@ -151,9 +157,15 @@ export default function RepeatEvent({
}
}}
>
<FormControlLabel value="never" control={<Radio />} label="Never" />
<FormControlLabel
disabled={!isOwn}
value="never"
control={<Radio />}
label="Never"
/>
<FormControlLabel
disabled={!isOwn}
value="after"
control={<Radio />}
label={
@@ -172,7 +184,7 @@ export default function RepeatEvent({
}
style={{ width: 100 }}
inputProps={{ min: 1 }}
disabled={endOption !== "after"}
disabled={!isOwn || endOption !== "after"}
/>
occurrences
</Box>
@@ -180,6 +192,7 @@ export default function RepeatEvent({
/>
<FormControlLabel
disabled={!isOwn}
value="on"
control={<Radio />}
label={
@@ -0,0 +1,91 @@
import { ThunkDispatch } from "@reduxjs/toolkit";
import { useAppSelector } from "../../../app/hooks";
import {
updateEventInstanceAsync,
updateSeriesAsync,
putEventAsync,
deleteEventInstanceAsync,
deleteEventAsync,
} from "../../../features/Calendars/CalendarSlice";
import { Calendars } from "../../../features/Calendars/CalendarTypes";
import { getEvent } from "../../../features/Events/EventApi";
import { CalendarEvent } from "../../../features/Events/EventsTypes";
import { userData } from "../../../features/User/userDataTypes";
import { getCalendarRange } from "../../../utils/dateUtils";
import { refreshCalendars } from "../utils/eventUtils";
export async function handleRSVP(
dispatch: ThunkDispatch<any, any, any>,
calendar: Calendars,
user: { userData: userData },
event: CalendarEvent,
rsvp: string,
onClose?: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void,
typeOfAction?: string,
calendars?: Calendars[]
) {
const newEvent = {
...event,
attendee: event.attendee?.map((a) =>
a.cal_address === user.userData.email ? { ...a, partstat: rsvp } : a
),
};
if (typeOfAction === "solo") {
dispatch(updateEventInstanceAsync({ cal: calendar, event: newEvent }));
} else if (typeOfAction === "all") {
const master = await getEvent(newEvent, true);
const calendarRange = getCalendarRange(new Date(event.start));
dispatch(
updateSeriesAsync({
cal: calendar,
event: {
...master,
attendee: event.attendee?.map((a) =>
a.cal_address === user.userData.email ? { ...a, partstat: rsvp } : a
),
},
})
);
if (calendars) {
await refreshCalendars(dispatch, calendars, calendarRange);
}
} else {
dispatch(putEventAsync({ cal: calendar, newEvent }));
}
}
export function handleDelete(
isRecurring: boolean,
typeOfAction: "solo" | "all" | undefined,
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void,
dispatch: Function,
calendar: Calendars,
event: CalendarEvent,
calId: string,
eventId: string
) {
if (isRecurring) {
onClose({}, "backdropClick");
if (typeOfAction === "solo") {
dispatch(deleteEventInstanceAsync({ cal: calendar, event }));
} else {
dispatch(
deleteEventAsync({
calId,
eventId,
eventURL: event.URL,
})
);
}
} else {
onClose({}, "backdropClick");
dispatch(
deleteEventAsync({
calId,
eventId,
eventURL: event.URL,
})
);
}
}
+28
View File
@@ -4,7 +4,14 @@ import Avatar from "@mui/material/Avatar";
import Badge from "@mui/material/Badge";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { ThunkDispatch } from "@reduxjs/toolkit";
import {
getCalendarDetailAsync,
getCalendarsListAsync,
} from "../../../features/Calendars/CalendarSlice";
import { Calendars } from "../../../features/Calendars/CalendarTypes";
import { userAttendee } from "../../../features/User/userDataTypes";
import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils";
export function renderAttendeeBadge(
a: userAttendee,
@@ -103,3 +110,24 @@ export function stringAvatar(name: string) {
children: name[0],
};
}
export async function refreshCalendars(
dispatch: ThunkDispatch<any, any, any>,
calendars: Calendars[],
calendarRange: { start: Date; end: Date }
) {
await dispatch(getCalendarsListAsync());
calendars.map(
async (cal) =>
await dispatch(
getCalendarDetailAsync({
calId: cal.id,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
},
})
)
);
}
+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)