Merge pull request #95 from linagora/77-implement-complex-repetition-ui

implement complex repetition ui
This commit is contained in:
Camille Moussu
2025-09-09 11:50:37 +02:00
committed by GitHub
12 changed files with 869 additions and 366 deletions
+366
View File
@@ -0,0 +1,366 @@
import { screen, fireEvent, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../utils/Renderwithproviders";
import RepeatEvent from "../../src/components/Event/EventRepeat";
import EventPopover from "../../src/features/Events/EventModal";
import { RepetitionObject } from "../../src/features/Events/EventsTypes";
import { DateSelectArg } from "@fullcalendar/core";
import { formatDateToYYYYMMDDTHHMMSS } from "../../src/utils/dateUtils";
import * as eventThunks from "../../src/features/Calendars/CalendarSlice";
import * as apiUtils from "../../src/utils/apiUtils";
const baseRepetition: RepetitionObject = {
freq: "",
interval: 1,
occurrences: 0,
endDate: "",
selectedDays: [],
};
const mockOnClose = jest.fn();
const mockSetSelectedRange = jest.fn();
const mockCalendarRef = { current: { select: jest.fn() } } as any;
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro",
openpaasId: "667037022b752d0026472254",
},
organiserData: {
cn: "test",
cal_address: "test@test.com",
},
},
calendars: {
list: {
"667037022b752d0026472254/cal1": {
id: "667037022b752d0026472254/cal1",
name: "Calendar 1",
color: "#FF0000",
},
"667037022b752d0026472254/cal2": {
id: "667037022b752d0026472254/cal2",
name: "Calendar 2",
color: "#00FF00",
},
},
pending: false,
},
};
const defaultSelectedRange = {
startStr: "2025-07-18T09:00",
endStr: "2025-07-18T10:00",
start: new Date("2025-07-18T09:00"),
end: new Date("2025-07-18T10:00"),
allDay: false,
resource: undefined,
} as unknown as DateSelectArg;
function setupRepeatEvent(props?: Partial<RepetitionObject>, state?: any) {
const setRepetition = jest.fn();
renderWithProviders(
<RepeatEvent
repetition={{ ...baseRepetition, ...props }}
eventStart={defaultSelectedRange.start}
setRepetition={setRepetition}
isOwn={true}
/>,
state
);
return { setRepetition };
}
async function setupEventPopover(
overrides?: Partial<{ start: string; end: string }>
) {
jest
.spyOn(crypto, "randomUUID")
.mockReturnValue("fixed-uuid-with-correct-format");
const originalDateResolvedOptions =
new Intl.DateTimeFormat().resolvedOptions();
jest.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({
...originalDateResolvedOptions,
timeZone: "UTC",
});
renderWithProviders(
<EventPopover
anchorEl={document.body}
open={true}
onClose={mockOnClose}
selectedRange={defaultSelectedRange}
setSelectedRange={mockSetSelectedRange}
calendarRef={mockCalendarRef}
/>,
preloadedState
);
act(() => {
fireEvent.change(screen.getByLabelText("Title"), {
target: { value: "Meeting" },
});
fireEvent.click(screen.getByLabelText("All day"));
fireEvent.change(screen.getByLabelText("Start"), {
target: {
value: (overrides?.start ?? "2025-07-18T00:00:00.000Z").split("T")[0],
},
});
fireEvent.change(screen.getByLabelText("End"), {
target: {
value: (overrides?.end ?? "2025-07-19T00:00:00.000Z").split("T")[0],
},
});
fireEvent.click(screen.getByText("Show More"));
});
const select = screen.getByLabelText(/repetition/i);
userEvent.click(select);
return jest.spyOn(apiUtils, "api");
}
async function expectRRule(expected: any) {
const spyAPi = jest.spyOn(apiUtils, "api");
act(() => fireEvent.click(screen.getByText("Save")));
await waitFor(() => {
expect(spyAPi).toHaveBeenCalled();
});
const receivedPayload: string =
spyAPi.mock.calls[0][1]?.body?.toString() ?? "";
const [, , [vevent]] = JSON.parse(receivedPayload);
const rrule = vevent[1].find(([name]: any) => name === "rrule");
if (rrule[3].byday) {
expect({
...rrule[3],
byday: rrule[3].byday.sort(),
}).toEqual({
...expected,
byday: expected.byday.sort(),
});
} else {
expect(rrule[3]).toEqual(expected);
}
}
describe("RepeatEvent", () => {
it("renders with no repetition by default", () => {
setupRepeatEvent();
expect(screen.getByLabelText(/repetition/i)).toBeInTheDocument();
expect(screen.queryByText(/daily/i)).not.toBeInTheDocument();
});
it("allows selecting repetition frequency", async () => {
const { setRepetition } = setupRepeatEvent();
const select = screen.getByLabelText(/repetition/i);
act(() => {
userEvent.click(select);
});
await waitFor(async () =>
userEvent.click(await screen.findByText(/repeat weekly/i))
);
expect(setRepetition).toHaveBeenCalledWith(
expect.objectContaining({ freq: "weekly" })
);
});
it("renders interval input when frequency is selected", () => {
setupRepeatEvent({ freq: "daily", interval: 2 });
expect(screen.getByText(/interval/i)).toBeInTheDocument();
expect(screen.getByDisplayValue("2")).toBeInTheDocument();
});
it("updates interval value", () => {
const { setRepetition } = setupRepeatEvent({ freq: "daily", interval: 1 });
const input = screen.getByDisplayValue("1");
fireEvent.change(input, { target: { value: "5" } });
expect(setRepetition).toHaveBeenCalledWith(
expect.objectContaining({ interval: 5 })
);
});
it("toggles day selection for weekly frequency", () => {
const { setRepetition } = setupRepeatEvent({
freq: "weekly",
selectedDays: [],
});
act(() => {
const mondayCheckbox = screen.getByLabelText("MO");
fireEvent.click(mondayCheckbox);
});
expect(setRepetition).toHaveBeenCalledWith(
expect.objectContaining({ selectedDays: ["MO"] })
);
});
});
describe("Repeat Event API calls", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
it("sends correct CalendarEvent payload", async () => {
setupEventPopover();
userEvent.click(await screen.findByText(/repeat weekly/i));
const spy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => () => Promise.resolve(payload) as any);
act(() => fireEvent.click(screen.getByText("Save")));
await waitFor(() => expect(spy).toHaveBeenCalled());
const received = spy.mock.calls[0][0];
expect(received.cal).toEqual(
preloadedState.calendars.list["667037022b752d0026472254/cal1"]
);
expect(received.newEvent.title).toBe("Meeting");
expect(
formatDateToYYYYMMDDTHHMMSS(received.newEvent.start).split("T")[0]
).toBe("20250718");
expect(
formatDateToYYYYMMDDTHHMMSS(received.newEvent.end || new Date()).split(
"T"
)[0]
).toBe("20250719");
expect(received.newEvent.organizer).toEqual(
preloadedState.user.organiserData
);
const day = new Date(received.newEvent.start)
.toLocaleString("en-UK", {
weekday: "short",
})
.slice(0, 2)
.toUpperCase();
expect(received.newEvent.repetition).toEqual({
freq: "weekly",
selectedDays: [day],
});
expect(received.newEvent.color).toEqual(
preloadedState.calendars.list["667037022b752d0026472254/cal1"].color
);
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
it("sends correct API payload for repeat daily", async () => {
await setupEventPopover();
userEvent.click(await screen.findByText(/repeat daily/i));
await expectRRule({ freq: "daily" });
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
it("sends correct API payload for repeat daily with 2 day interval", async () => {
await setupEventPopover();
userEvent.click(await screen.findByText(/repeat daily/i));
const intervalInput = screen.getByDisplayValue("1");
fireEvent.change(intervalInput, { target: { value: "2" } });
await expectRRule({ freq: "daily", interval: 2 });
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
it("sends correct API payload for repeat daily for 5 repetitions", async () => {
await setupEventPopover();
userEvent.click(await screen.findByText(/repeat daily/i));
userEvent.click(screen.getByLabelText(/after/i));
const input = screen.getAllByRole("spinbutton")[1];
fireEvent.change(input, { target: { value: "5" } });
await expectRRule({ freq: "daily", count: 5 });
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
it("sends correct API payload for repeat daily until now+5days", async () => {
await setupEventPopover();
userEvent.click(await screen.findByText(/repeat daily/i));
userEvent.click(screen.getAllByLabelText(/on/i)[3]);
const untilInput = screen.getByTestId("end-date");
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 5);
fireEvent.change(untilInput, {
target: { value: futureDate.toISOString().split("T")[0] },
});
await expectRRule({
freq: "daily",
until: futureDate.toISOString().split("T")[0],
});
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
it("sends correct API payload for repeat weekly on Thursday and event day (Friday)", async () => {
await setupEventPopover();
userEvent.click(await screen.findByText(/repeat weekly/i));
userEvent.click(screen.getByLabelText("TH"));
await expectRRule({ freq: "weekly", byday: ["TH", "FR"] });
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
it("sends correct API payload for repeat weekly on Thursday and event day (Friday) and an interval of 3 weeks", async () => {
await setupEventPopover();
userEvent.click(await screen.findByText(/repeat weekly/i));
userEvent.click(screen.getByLabelText("TH"));
const intervalInput = screen.getByDisplayValue("1");
fireEvent.change(intervalInput, { target: { value: "3" } });
await expectRRule({ freq: "weekly", byday: ["TH", "FR"], interval: 3 });
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
it("sends correct API payload for repeat monthly", async () => {
await setupEventPopover();
userEvent.click(await screen.findByText(/repeat monthly/i));
await expectRRule({ freq: "monthly" });
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
it("sends correct API payload for repeat monthly and end after 5 occurrences", async () => {
await setupEventPopover();
userEvent.click(await screen.findByText(/repeat monthly/i));
userEvent.click(screen.getByLabelText(/after/i));
const input = screen.getAllByRole("spinbutton")[1];
fireEvent.change(input, { target: { value: "5" } });
await expectRRule({ freq: "monthly", count: 5 });
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
it("sends correct API payload for repeat yearly", async () => {
await setupEventPopover();
userEvent.click(await screen.findByText(/repeat yearly/i));
await expectRRule({ freq: "yearly" });
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
it("sends correct API payload for repeat yearly, but user first choose to end after 5 occurrences then changed mind and chose to not end", async () => {
await setupEventPopover();
userEvent.click(await screen.findByText(/repeat yearly/i));
userEvent.click(screen.getByLabelText(/after/i));
const input = screen.getAllByRole("spinbutton")[1];
fireEvent.change(input, { target: { value: "5" } });
userEvent.click(screen.getByLabelText(/never/i));
await expectRRule({ freq: "yearly" });
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
});
});
+31 -16
View File
@@ -474,6 +474,37 @@ describe("Event Preview Display", () => {
const updatedEvent = spy.mock.calls[0][0].newEvent;
expect(updatedEvent.attendee[0].partstat).toBe("DECLINED");
});
it("handles Edit click", async () => {
const spy = jest
.spyOn(eventThunks, "getEventAsync")
.mockImplementation((payload) => {
return () =>
Promise.resolve({
calId: payload.calId,
event:
preloadedState.calendars.list["667037022b752d0026472254/cal1"]
.events["event1"],
}) as any;
});
renderWithProviders(
<EventPreviewModal
anchorPosition={{ top: 0, left: 0 }}
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"event1"}
/>,
preloadedState
);
fireEvent.click(screen.getByTestId("EditIcon"));
await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
expect(screen.getByText("Edit Event")).toBeInTheDocument();
});
});
describe("Event Full Display", () => {
@@ -917,18 +948,6 @@ describe("Event Full Display", () => {
expect(updatedEvent.attendee[0].partstat).toBe("DECLINED");
});
it("toggle Show More reveals extra fields", async () => {
const spy = jest
.spyOn(eventThunks, "getEventAsync")
.mockImplementation((payload) => {
return () =>
Promise.resolve({
calId: payload.calId,
event:
preloadedState.calendars.list["667037022b752d0026472254/cal1"]
.events["event1"],
}) as any;
});
renderWithProviders(
<EventDisplayModal
open={true}
@@ -942,10 +961,6 @@ describe("Event Full Display", () => {
fireEvent.click(screen.getByText("Show More"));
});
await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
console.log(spy);
await waitFor(() => {
expect(screen.getByLabelText(/Alarm/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Repetition/i)).toBeInTheDocument();
@@ -286,7 +286,6 @@ describe("EventPopover", () => {
preloadedState.calendars.list["667037022b752d0026472254/cal1"]
);
console.log(receivedPayload.newEvent.start);
expect(receivedPayload.newEvent.title).toBe(newEvent.title);
expect(receivedPayload.newEvent.description).toBe(newEvent.description);
expect(
+2 -2
View File
@@ -280,7 +280,7 @@ describe("calendarEventToJCal", () => {
allday: true,
location: "Room 101",
description: "Discuss project roadmap.",
repetition: { freq: "WEEKLY" },
repetition: { freq: "WEEKLY", interval: 2 },
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
@@ -315,7 +315,7 @@ describe("calendarEventToJCal", () => {
["location", {}, "text", "Room 101"],
["description", {}, "text", "Discuss project roadmap."],
["x-openpaas-videoconference", {}, "unknown", null],
["rrule", {}, "recur", { freq: "WEEKLY" }],
["rrule", {}, "recur", { freq: "WEEKLY", interval: 2 }],
[
"organizer",
{ cn: "Alice" },
+6 -4
View File
@@ -32,10 +32,12 @@ export default function UserSearch({
useEffect(() => {
const delayDebounceFn = setTimeout(async () => {
setLoading(true);
const res = await searchUsers(query);
setOptions(res);
setLoading(false);
if (query) {
setLoading(true);
const res = await searchUsers(query);
setOptions(res);
setLoading(false);
}
}, 300);
return () => clearTimeout(delayDebounceFn);
+15 -2
View File
@@ -14,6 +14,7 @@ import { CalendarEvent } from "../../features/Events/EventsTypes";
import CalendarSelection from "./CalendarSelection";
import {
getCalendarDetailAsync,
getEventAsync,
putEventAsync,
updateEventLocal,
} from "../../features/Calendars/CalendarSlice";
@@ -326,11 +327,21 @@ export default function CalendarApp() {
setEventDisplayedCalId(info.event.extendedProps.calId);
}
}}
eventAllow={(dropInfo, draggedEvent) => {
if (
draggedEvent?.extendedProps.uid &&
draggedEvent.extendedProps.uid.split("/")[1]
) {
return false;
}
return true;
}}
eventDrop={(arg) => {
const event =
calendars[arg.event._def.extendedProps.calId].events[
arg.event._def.extendedProps.uid
];
const totalDeltaMs = getDeltaInMilliseconds(arg.delta);
const originalStart = new Date(event.start);
@@ -358,7 +369,9 @@ export default function CalendarApp() {
calendars[arg.event._def.extendedProps.calId].events[
arg.event._def.extendedProps.uid
];
if (event.uid.split("/")[1]) {
dispatch(getEventAsync(event));
}
const originalStart = new Date(event.start);
const computedNewStart = new Date(
originalStart.getTime() + getDeltaInMilliseconds(arg.startDelta)
@@ -372,7 +385,7 @@ export default function CalendarApp() {
start: computedNewStart,
end: computedNewEnd,
} as CalendarEvent;
console.log(event , newEvent);
console.log(event, newEvent);
dispatch(
putEventAsync({ cal: calendars[newEvent.calId], newEvent })
);
+92 -28
View File
@@ -6,46 +6,56 @@ import {
MenuItem,
Box,
Stack,
Paper,
Typography,
TextField,
Checkbox,
List,
ListItem,
FormControlLabel,
FormGroup,
Radio,
RadioGroup,
} from "@mui/material";
import { useState } from "react";
import { useEffect, useState } from "react";
import { RepetitionObject } from "../../features/Events/EventsTypes";
export default function RepeatEvent({
repetition,
eventStart,
setRepetition,
isOwn = true,
}: {
repetition: RepetitionObject;
eventStart: Date;
setRepetition: Function;
isOwn?: boolean;
}) {
console.log(JSON.stringify(repetition));
const repetitionValues = ["day", "week", "month", "year"];
const [interval, setInterval] = useState(repetition.interval ?? 0);
const [selectedDays, setSelectedDays] = useState<string[]>(
repetition.selectedDays ?? []
);
const [endOption, setEndOption] = useState("");
const [occurrences, setOccurrences] = useState(repetition.occurrences) ?? 0;
const [endDate, setEndDate] = useState(repetition.endDate ?? "");
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";
if (repetition.endDate) return "on";
return "never";
};
const [endOption, setEndOption] = useState(getEndOption());
// keep endOption in sync if repetition changes from parent
useEffect(() => {
if (!endOption) {
setEndOption(getEndOption());
}
}, [repetition.occurrences, repetition.endDate]);
const handleDayChange = (day: string) => {
setSelectedDays((prev: string[]) =>
prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day]
);
const updatedDays = repetition.selectedDays?.includes(day)
? repetition.selectedDays.filter((d) => d !== day)
: [...(repetition.selectedDays ?? []), day];
setRepetition({ ...repetition, selectedDays: updatedDays });
};
return (
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="repeat">Repetition</InputLabel>
@@ -54,9 +64,17 @@ export default function RepeatEvent({
value={repetition.freq ?? ""}
disabled={!isOwn}
label="Repetition"
onChange={(e: SelectChangeEvent) =>
setRepetition({ ...repetition, freq: e.target.value })
}
onChange={(e: SelectChangeEvent) => {
if (e.target.value === "weekly") {
setRepetition({
...repetition,
freq: e.target.value,
selectedDays: [days[day.getDay() - 1]],
});
} else {
setRepetition({ ...repetition, freq: e.target.value });
}
}}
>
<MenuItem value={""}>No Repetition</MenuItem>
<MenuItem value={"daily"}>Repeat daily</MenuItem>
@@ -64,18 +82,24 @@ export default function RepeatEvent({
<MenuItem value={"monthly"}>Repeat monthly</MenuItem>
<MenuItem value={"yearly"}>Repeat yearly</MenuItem>
</Select>
{repetition.freq && (
<Stack>
{/* Interval */}
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Typography>Interval:</Typography>
<TextField
type="number"
value={interval}
onChange={(e) => setInterval(Number(e.target.value))}
value={repetition.interval ?? 1}
onChange={(e) =>
setRepetition({
...repetition,
interval: Number(e.target.value),
})
}
size="small"
sx={{ width: 80 }}
/>
<Typography>
{
repetitionValues[
@@ -84,6 +108,8 @@ export default function RepeatEvent({
}
</Typography>
</Box>
{/* Weekly selection */}
{repetition.freq === "weekly" && (
<Box>
<Typography variant="body2" gutterBottom>
@@ -95,7 +121,9 @@ export default function RepeatEvent({
key={day}
control={
<Checkbox
checked={selectedDays.includes(day)}
checked={
repetition.selectedDays?.includes(day) ?? false
}
onChange={() => handleDayChange(day)}
/>
}
@@ -105,13 +133,36 @@ export default function RepeatEvent({
</FormGroup>
</Box>
)}
{/* End options */}
<Box>
<Typography variant="body2" gutterBottom sx={{ mt: 2 }}>
End:
</Typography>
<RadioGroup
value={endOption}
onChange={(e) => setEndOption(e.target.value)}
onChange={(e) => {
const value = e.target.value;
setEndOption(value);
if (value === "never") {
setRepetition({ ...repetition, occurrences: 0, endDate: "" });
}
if (value === "after") {
setRepetition({
...repetition,
occurrences: 0,
endDate: "",
});
}
if (value === "on") {
setRepetition({
...repetition,
occurrences: 0,
endDate: new Date().toISOString().slice(0, 16),
});
}
}}
>
<FormControlLabel
value="never"
@@ -128,8 +179,14 @@ export default function RepeatEvent({
<TextField
type="number"
size="small"
value={occurrences}
onChange={(e) => setOccurrences(Number(e.target.value))}
value={repetition.occurrences ?? 0}
onChange={(e) =>
setRepetition({
...repetition,
endDate: "",
occurrences: Number(e.target.value),
})
}
sx={{ width: 100 }}
inputProps={{ min: 1 }}
disabled={endOption !== "after"}
@@ -147,9 +204,16 @@ export default function RepeatEvent({
On
<TextField
type="date"
inputProps={{ "data-testid": "end-date" }}
size="small"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
value={repetition.endDate ?? ""}
onChange={(e) =>
setRepetition({
...repetition,
occurrences: 0,
endDate: e.target.value,
})
}
disabled={endOption !== "on"}
/>
</Box>
+25 -5
View File
@@ -71,11 +71,18 @@ export const putEventAsync = createAsyncThunk<
{ cal: Calendars; newEvent: CalendarEvent } // Arg type
>("calendars/putEvent", async ({ cal, newEvent }) => {
const response = await putEvent(newEvent);
const eventDate = new Date(newEvent.start);
const weekStart = new Date(eventDate);
weekStart.setHours(0, 0, 0, 0);
weekStart.setDate(eventDate.getDate() - eventDate.getDay());
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 7);
const calEvents = (await getCalendar(cal.id, {
start: formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.start)),
end: formatDateToYYYYMMDDTHHMMSS(
new Date(new Date(newEvent.start).getTime() + 86400000)
),
start: formatDateToYYYYMMDDTHHMMSS(weekStart),
end: formatDateToYYYYMMDDTHHMMSS(weekEnd),
})) as Record<string, any>;
const events: CalendarEvent[] = calEvents._embedded["dav:item"].flatMap(
(eventdata: any) => {
@@ -291,7 +298,20 @@ const CalendarSlice = createSlice({
)
.addCase(deleteEventAsync.fulfilled, (state, action) => {
state.pending = false;
delete state.list[action.payload.calId].events[action.payload.eventId];
const [baseId, recurrenceId] = action.payload.eventId.split("/");
if (recurrenceId) {
Object.keys(state.list[action.payload.calId].events).forEach(
(element) => {
if (element.split("/")[0] === baseId) {
delete state.list[action.payload.calId].events[element];
}
}
);
} else {
delete state.list[action.payload.calId].events[
action.payload.eventId
];
}
})
.addCase(getCalendarDetailAsync.pending, (state) => {
state.pending = true;
+328 -306
View File
@@ -124,7 +124,8 @@ export default function EventDisplayModal({
if (!event || !calendar) {
onClose({}, "backdropClick");
}
}, [open, eventId, dispatch, onClose]);
setRepetition(event.repetition ?? ({} as RepetitionObject));
}, [open, eventId, dispatch, onClose, event]);
if (!event || !calendar) return null;
@@ -161,6 +162,16 @@ export default function EventDisplayModal({
color: userPersonnalCalendars[calendarid]?.color,
};
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 }));
}
}
);
}
await dispatch(
putEventAsync({
cal: userPersonnalCalendars[calendarid],
@@ -181,13 +192,7 @@ export default function EventDisplayModal({
onClose({}, "backdropClick");
};
const [detailsLoaded, setDetailsLoaded] = useState(false);
const handleToggleShowMore = async () => {
if (!detailsLoaded) {
await dispatch(getEventAsync(event));
setDetailsLoaded(true);
}
setShowMore(!showMore);
};
@@ -224,315 +229,332 @@ export default function EventDisplayModal({
return (
<Modal open={open} onClose={onClose}>
<Card sx={{ minWidth: 300, width: "50vw", p: 2, position: "absolute" }}>
{/* Close button */}
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<IconButton size="small" onClick={() => onClose({}, "backdropClick")}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<CardHeader title={isOwn ? "Edit Event" : "Event Details"} />
<CardContent sx={{ overflow: "auto" }}>
{/* Title */}
<TextField
fullWidth
disabled={!isOwn}
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
size="small"
margin="dense"
/>
{/* RSVP */}
{currentUserAttendee && isOwnCal && (
<Card sx={{ my: 1 }}>
<ButtonGroup size="small" fullWidth>
<Button
color={
currentUserAttendee.partstat === "ACCEPTED"
? "success"
: "primary"
}
onClick={() => handleRSVP("ACCEPTED")}
>
Accept
</Button>
<Button
color={
currentUserAttendee.partstat === "TENTATIVE"
? "warning"
: "primary"
}
onClick={() => handleRSVP("TENTATIVE")}
>
Maybe
</Button>
<Button
color={
currentUserAttendee.partstat === "DECLINED"
? "error"
: "primary"
}
onClick={() => handleRSVP("DECLINED")}
>
Decline
</Button>
<Button
color="primary"
onClick={() => console.log("proposenewtime")}
>
Propose new time
</Button>
</ButtonGroup>
</Card>
)}
{/* Calendar selector */}
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="calendar-select-label">Calendar</InputLabel>
<Select
disabled={!isOwn}
labelId="calendar-select-label"
value={calendarid.toString()}
label="Calendar"
onChange={(e: SelectChangeEvent) => {
const newId = Number(e.target.value);
setCalendarid(newId);
setNewCalId(userPersonnalCalendars[newId].id);
}}
<Box
sx={{
position: "absolute",
top: "5vh",
left: "50%",
transform: "translate(-50%, -50%)",
width: "50vw",
maxHeight: "80vh",
}}
>
<Card sx={{ p: 2, position: "absolute" }}>
{/* Close button */}
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<IconButton
size="small"
onClick={() => onClose({}, "backdropClick")}
>
{calList}
</Select>
</FormControl>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
{/* Dates */}
<TextField
fullWidth
label="Start"
disabled={!isOwn}
type={allday ? "date" : "datetime-local"}
value={allday ? start.split("T")[0] : start.slice(0, 16)}
onChange={(e) =>
setStart(formatLocalDateTime(new Date(e.target.value)))
}
size="small"
margin="dense"
InputLabelProps={{ shrink: true }}
/>
<CardHeader title={isOwn ? "Edit Event" : "Event Details"} />
<TextField
fullWidth
disabled={!isOwn}
label="End"
type={allday ? "date" : "datetime-local"}
value={allday ? end.split("T")[0] : end.slice(0, 16)}
onChange={(e) =>
setEnd(formatLocalDateTime(new Date(e.target.value)))
}
size="small"
margin="dense"
InputLabelProps={{ shrink: true }}
/>
<CardContent sx={{ maxHeight: "75vh", overflow: "auto" }}>
{/* Title */}
<TextField
fullWidth
disabled={!isOwn}
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
size="small"
margin="dense"
/>
<FormControlLabel
control={
<Checkbox
{/* RSVP */}
{currentUserAttendee && isOwnCal && (
<Card sx={{ my: 1 }}>
<ButtonGroup size="small" fullWidth>
<Button
color={
currentUserAttendee.partstat === "ACCEPTED"
? "success"
: "primary"
}
onClick={() => handleRSVP("ACCEPTED")}
>
Accept
</Button>
<Button
color={
currentUserAttendee.partstat === "TENTATIVE"
? "warning"
: "primary"
}
onClick={() => handleRSVP("TENTATIVE")}
>
Maybe
</Button>
<Button
color={
currentUserAttendee.partstat === "DECLINED"
? "error"
: "primary"
}
onClick={() => handleRSVP("DECLINED")}
>
Decline
</Button>
<Button
color="primary"
onClick={() => console.log("proposenewtime")}
>
Propose new time
</Button>
</ButtonGroup>
</Card>
)}
{/* Calendar selector */}
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="calendar-select-label">Calendar</InputLabel>
<Select
disabled={!isOwn}
checked={allday}
onChange={() => {
const endDate = new Date(end);
const startDate = new Date(start);
setAllDay(!allday);
if (endDate.getDate() === startDate.getDate()) {
endDate.setDate(startDate.getDate() + 1);
setEnd(formatLocalDateTime(endDate));
}
}}
/>
}
label="All day"
/>
{/* Description & Location */}
<TextField
fullWidth
disabled={!isOwn}
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
size="small"
margin="dense"
multiline
rows={2}
/>
{isOwn && (
<AttendeeSelector
attendees={attendees}
setAttendees={(value: userAttendee[]) => {
const newAttendeeList = attendees.concat(value);
setAttendees(newAttendeeList);
}}
/>
)}
<TextField
fullWidth
label="Location"
disabled={!isOwn}
value={location}
onChange={(e) => setLocation(e.target.value)}
size="small"
margin="dense"
/>
{/* Video */}
{event.x_openpass_videoconference && (
<InfoRow
icon={<VideocamIcon sx={{ fontSize: 18 }} />}
text="Video conference available"
data={event.x_openpass_videoconference}
/>
)}
{/* Attendees */}
{event.attendee?.length > 0 && (
<Box sx={{ mb: 1 }}>
<Typography variant="subtitle2">Attendees:</Typography>
{organizer.cal_address &&
renderAttendeeBadge(organizer, "org", true)}
{(showAllAttendees
? attendees
: attendees.slice(0, attendeeDisplayLimit)
).map((a, idx) => (
<Box key={a.cal_address}>
{renderAttendeeBadge(a, idx.toString())}
{isOwn && (
<IconButton
size="small"
onClick={() => {
const newAttendeesList = [...attendees];
newAttendeesList.splice(idx, 1);
setAttendees(newAttendeesList);
}}
>
<CloseIcon fontSize="small" />
</IconButton>
)}
</Box>
))}
{attendees.length > attendeeDisplayLimit && (
<Typography
variant="body2"
color="primary"
sx={{ cursor: "pointer", mt: 0.5 }}
onClick={() => setShowAllAttendees(!showAllAttendees)}
>
{showAllAttendees
? "Show less"
: `Show more (${
attendees.length - attendeeDisplayLimit
} more)`}
</Typography>
)}
</Box>
)}
<Divider sx={{ my: 1 }} />
{/* Extended options */}
{showMore && (
<>
<RepeatEvent
repetition={repetition}
setRepetition={setRepetition}
isOwn={isOwn}
/>
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="alarm">Alarm</InputLabel>
<Select
labelId="alarm"
value={alarm}
disabled={!isOwn}
onChange={(e: SelectChangeEvent) => setAlarm(e.target.value)}
>
<MenuItem value={""}>No Alarm</MenuItem>
<MenuItem value={"-PT1M"}>1 minute</MenuItem>
<MenuItem value={"-PT5M"}>2 minutes</MenuItem>
<MenuItem value={"-PT10M"}>10 minutes</MenuItem>
<MenuItem value={"-PT15M"}>15 minutes</MenuItem>
<MenuItem value={"-PT30M"}>30 minutes</MenuItem>
<MenuItem value={"-PT1H"}>1 hours</MenuItem>
<MenuItem value={"-PT2H"}>2 hours</MenuItem>
<MenuItem value={"-PT5H"}>5 hours</MenuItem>
<MenuItem value={"-PT12H"}>12 hours</MenuItem>
<MenuItem value={"-PT1D"}>1 day</MenuItem>
<MenuItem value={"-PT2D"}>2 days</MenuItem>
<MenuItem value={"-PT1W"}>1 week</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="Visibility">Visibility</InputLabel>
<Select
labelId="Visibility"
label="Visibility"
value={eventClass}
disabled={!isOwn}
onChange={(e: SelectChangeEvent) =>
setEventClass(e.target.value)
}
>
<MenuItem value={"PUBLIC"}>Public</MenuItem>
<MenuItem value={"CONFIDENTIAL"}>Show time only</MenuItem>
<MenuItem value={"PRIVATE"}>Private</MenuItem>
</Select>
</FormControl>
{/* Error */}
{event.error && (
<InfoRow
icon={
<ErrorOutlineIcon color="error" sx={{ fontSize: 18 }} />
}
text={event.error}
error
/>
)}
</>
)}
</CardContent>
<CardActions>
<ButtonGroup>
{isOwn && (
<IconButton
size="small"
onClick={() => {
onClose({}, "backdropClick");
dispatch(
deleteEventAsync({ calId, eventId, eventURL: event.URL })
);
labelId="calendar-select-label"
value={calendarid.toString()}
label="Calendar"
onChange={(e: SelectChangeEvent) => {
const newId = Number(e.target.value);
setCalendarid(newId);
setNewCalId(userPersonnalCalendars[newId].id);
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
<Button size="small" onClick={handleToggleShowMore}>
{showMore ? "Show Less" : "Show More"}
</Button>
{calList}
</Select>
</FormControl>
{/* Dates */}
<TextField
fullWidth
label="Start"
disabled={!isOwn}
type={allday ? "date" : "datetime-local"}
value={allday ? start.split("T")[0] : start.slice(0, 16)}
onChange={(e) =>
setStart(formatLocalDateTime(new Date(e.target.value)))
}
size="small"
margin="dense"
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
disabled={!isOwn}
label="End"
type={allday ? "date" : "datetime-local"}
value={allday ? end.split("T")[0] : end.slice(0, 16)}
onChange={(e) =>
setEnd(formatLocalDateTime(new Date(e.target.value)))
}
size="small"
margin="dense"
InputLabelProps={{ shrink: true }}
/>
<FormControlLabel
control={
<Checkbox
disabled={!isOwn}
checked={allday}
onChange={() => {
const endDate = new Date(end);
const startDate = new Date(start);
setAllDay(!allday);
if (endDate.getDate() === startDate.getDate()) {
endDate.setDate(startDate.getDate() + 1);
setEnd(formatLocalDateTime(endDate));
}
}}
/>
}
label="All day"
/>
{/* Description & Location */}
<TextField
fullWidth
disabled={!isOwn}
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
size="small"
margin="dense"
multiline
rows={2}
/>
{isOwn && (
<Button size="small" onClick={handleSave}>
Save
</Button>
<AttendeeSelector
attendees={attendees}
setAttendees={(value: userAttendee[]) => {
const newAttendeeList = attendees.concat(value);
setAttendees(newAttendeeList);
}}
/>
)}
</ButtonGroup>
</CardActions>
</Card>
<TextField
fullWidth
label="Location"
disabled={!isOwn}
value={location}
onChange={(e) => setLocation(e.target.value)}
size="small"
margin="dense"
/>
{/* Video */}
{event.x_openpass_videoconference && (
<InfoRow
icon={<VideocamIcon sx={{ fontSize: 18 }} />}
text="Video conference available"
data={event.x_openpass_videoconference}
/>
)}
{/* Attendees */}
{event.attendee?.length > 0 && (
<Box sx={{ mb: 1 }}>
<Typography variant="subtitle2">Attendees:</Typography>
{organizer.cal_address &&
renderAttendeeBadge(organizer, "org", true)}
{(showAllAttendees
? attendees
: attendees.slice(0, attendeeDisplayLimit)
).map((a, idx) => (
<Box key={a.cal_address}>
{renderAttendeeBadge(a, idx.toString())}
{isOwn && (
<IconButton
size="small"
onClick={() => {
const newAttendeesList = [...attendees];
newAttendeesList.splice(idx, 1);
setAttendees(newAttendeesList);
}}
>
<CloseIcon fontSize="small" />
</IconButton>
)}
</Box>
))}
{attendees.length > attendeeDisplayLimit && (
<Typography
variant="body2"
color="primary"
sx={{ cursor: "pointer", mt: 0.5 }}
onClick={() => setShowAllAttendees(!showAllAttendees)}
>
{showAllAttendees
? "Show less"
: `Show more (${
attendees.length - attendeeDisplayLimit
} more)`}
</Typography>
)}
</Box>
)}
<Divider sx={{ my: 1 }} />
{/* Extended options */}
{showMore && (
<>
<RepeatEvent
repetition={repetition}
eventStart={event.start}
setRepetition={setRepetition}
isOwn={isOwn}
/>
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="alarm">Alarm</InputLabel>
<Select
labelId="alarm"
value={alarm}
disabled={!isOwn}
onChange={(e: SelectChangeEvent) =>
setAlarm(e.target.value)
}
>
<MenuItem value={""}>No Alarm</MenuItem>
<MenuItem value={"-PT1M"}>1 minute</MenuItem>
<MenuItem value={"-PT5M"}>2 minutes</MenuItem>
<MenuItem value={"-PT10M"}>10 minutes</MenuItem>
<MenuItem value={"-PT15M"}>15 minutes</MenuItem>
<MenuItem value={"-PT30M"}>30 minutes</MenuItem>
<MenuItem value={"-PT1H"}>1 hours</MenuItem>
<MenuItem value={"-PT2H"}>2 hours</MenuItem>
<MenuItem value={"-PT5H"}>5 hours</MenuItem>
<MenuItem value={"-PT12H"}>12 hours</MenuItem>
<MenuItem value={"-PT1D"}>1 day</MenuItem>
<MenuItem value={"-PT2D"}>2 days</MenuItem>
<MenuItem value={"-PT1W"}>1 week</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="Visibility">Visibility</InputLabel>
<Select
labelId="Visibility"
label="Visibility"
value={eventClass}
disabled={!isOwn}
onChange={(e: SelectChangeEvent) =>
setEventClass(e.target.value)
}
>
<MenuItem value={"PUBLIC"}>Public</MenuItem>
<MenuItem value={"CONFIDENTIAL"}>Show time only</MenuItem>
<MenuItem value={"PRIVATE"}>Private</MenuItem>
</Select>
</FormControl>
{/* Error */}
{event.error && (
<InfoRow
icon={
<ErrorOutlineIcon color="error" sx={{ fontSize: 18 }} />
}
text={event.error}
error
/>
)}
</>
)}
</CardContent>
<CardActions>
<ButtonGroup>
{isOwn && (
<IconButton
size="small"
onClick={() => {
onClose({}, "backdropClick");
dispatch(
deleteEventAsync({ calId, eventId, eventURL: event.URL })
);
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
<Button size="small" onClick={handleToggleShowMore}>
{showMore ? "Show Less" : "Show More"}
</Button>
{isOwn && (
<Button size="small" onClick={handleSave}>
Save
</Button>
)}
</ButtonGroup>
</CardActions>
</Card>
</Box>
</Modal>
);
}
@@ -136,6 +136,7 @@ export default function EventPreviewModal({
size="small"
onClick={async () => {
setOpenFullDisplay(!openFullDisplay);
await dispatch(getEventAsync(event));
}}
>
<EditIcon fontSize="small" />
+1
View File
@@ -284,6 +284,7 @@ function EventPopover({
<>
<RepeatEvent
repetition={repetition}
eventStart={selectedRange?.start ?? new Date()}
setRepetition={setRepetition}
/>
<FormControl fullWidth margin="dense" size="small">
+2 -2
View File
@@ -88,10 +88,10 @@ export function parseCalendarEvent(
event.repetition.selectedDays = value.byday;
}
if (value.until) {
event.repetition.selectedDays = value.endDate;
event.repetition.endDate = value.until;
}
if (value.count) {
event.repetition.selectedDays = value.occurrences;
event.repetition.occurrences = value.count;
}
if (value.interval) {
event.repetition.interval = value.interval;