[#77] added complex ui for repetition, fixed broken tests due to new feature

This commit is contained in:
Camille Moussu
2025-09-02 18:14:55 +02:00
parent b6f55da506
commit ee01e1d027
14 changed files with 344 additions and 90 deletions
+11 -10
View File
@@ -2,7 +2,7 @@ import { CalendarApi } from "@fullcalendar/core";
import { jest } from "@jest/globals";
import { ThunkDispatch } from "@reduxjs/toolkit";
import "@testing-library/jest-dom";
import { screen } from "@testing-library/react";
import { act, screen } from "@testing-library/react";
import * as appHooks from "../../src/app/hooks";
import CalendarApp from "../../src/components/Calendar/Calendar";
import { renderWithProviders } from "../utils/Renderwithproviders";
@@ -88,16 +88,17 @@ describe("CalendarApp integration", () => {
{ timeout: 3000 }
);
expect(eventEl).toBeInTheDocument();
act(() => {
if (calendarApi) {
const fcEvent = calendarApi.getEventById("event1");
expect(fcEvent?.title).toBe("Test Event");
const oldEnd = new Date(today.getTime() + 3600000); // +1 hour
const newEnd = new Date(oldEnd.getTime() + 1800000); // +30 min
if (calendarApi) {
const fcEvent = calendarApi.getEventById("event1");
expect(fcEvent?.title).toBe("Test Event");
const oldEnd = new Date(today.getTime() + 3600000); // +1 hour
const newEnd = new Date(oldEnd.getTime() + 1800000); // +30 min
fcEvent?.setEnd(newEnd);
fcEvent?.setEnd(newEnd);
expect(dispatch).toHaveBeenCalled();
}
expect(dispatch).toHaveBeenCalled();
}
});
});
});
+34 -12
View File
@@ -1,4 +1,4 @@
import { screen, fireEvent, waitFor } from "@testing-library/react";
import { screen, fireEvent, waitFor, act } from "@testing-library/react";
import * as eventThunks from "../../../src/features/Calendars/CalendarSlice";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import EventDisplayModal, {
@@ -7,6 +7,7 @@ import EventDisplayModal, {
stringToColor,
} from "../../../src/features/Events/EventDisplay";
import EventPreviewModal from "../../../src/features/Events/EventDisplayPreview";
describe("Event Preview Display", () => {
const mockOnClose = jest.fn();
const day = new Date();
@@ -915,7 +916,19 @@ describe("Event Full Display", () => {
const updatedEvent = spy.mock.calls[0][0].newEvent;
expect(updatedEvent.attendee[0].partstat).toBe("DECLINED");
});
test("toggle Show More reveals extra fields", () => {
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}
@@ -925,14 +938,23 @@ describe("Event Full Display", () => {
/>,
preloadedState
);
fireEvent.click(screen.getByText("Show More"));
expect(screen.getByLabelText("Alarm")).toBeInTheDocument();
expect(screen.getByLabelText("Repetition")).toBeInTheDocument();
expect(screen.getByLabelText("Visibility")).toBeInTheDocument();
act(() => {
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();
expect(screen.getByLabelText(/Visibility/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Show Less"));
});
test("can edit title when user is organizer", () => {
it("can edit title when user is organizer", () => {
renderWithProviders(
<EventDisplayModal
open={true}
@@ -946,7 +968,7 @@ describe("Event Full Display", () => {
fireEvent.change(titleField, { target: { value: "New Title" } });
expect(screen.getByDisplayValue("New Title")).toBeInTheDocument();
});
test("calendar select is disabled when not organizer", () => {
it("calendar select is disabled when not organizer", () => {
const rsvpState = {
...preloadedState,
calendars: {
@@ -988,7 +1010,7 @@ describe("Event Full Display", () => {
);
expect(screen.getByLabelText("Calendar")).toHaveClass("Mui-disabled");
});
test("toggle all-day updates end date correctly", () => {
it("toggle all-day updates end date correctly", () => {
renderWithProviders(
<EventDisplayModal
open={true}
@@ -1010,18 +1032,18 @@ describe("Event Full Display", () => {
});
describe("Helper functions", () => {
test("stringToColor generates consistent color", () => {
it("stringToColor generates consistent color", () => {
expect(stringToColor("Alice")).toMatch(/^#[0-9a-f]{6}$/);
expect(stringToColor("Alice")).toBe(stringToColor("Alice"));
});
test("stringAvatar returns correct props", () => {
it("stringAvatar returns correct props", () => {
const result = stringAvatar("Alice");
expect(result.children).toBe("A");
expect(result.sx.bgcolor).toMatch(/^#/);
});
test("InfoRow renders text and link if url is valid", () => {
it("InfoRow renders text and link if url is valid", () => {
renderWithProviders(
<InfoRow
icon={<span>ico</span>}
@@ -247,7 +247,6 @@ describe("EventPopover", () => {
uid: "6045c603-11ab-43c5-bc30-0641420bb3a8",
description: "Discuss project",
location: "Zoom",
repetition: "",
organizer: { cn: "test", cal_address: "test@test.com" },
timezone: "Europe/Paris",
transp: "OPAQUE",
@@ -302,7 +301,6 @@ describe("EventPopover", () => {
).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.end)).split("T")[0]);
expect(receivedPayload.newEvent.location).toBe(newEvent.location);
expect(receivedPayload.newEvent.organizer).toEqual(newEvent.organizer);
expect(receivedPayload.newEvent.repetition).toEqual(newEvent.repetition);
expect(receivedPayload.newEvent.color).toEqual(
preloadedState.calendars.list["667037022b752d0026472254/cal1"].color
);
+2 -2
View File
@@ -167,7 +167,7 @@ describe("calendarEventToJCal", () => {
allday: false,
location: "Room 101",
description: "Discuss project roadmap.",
repetition: "WEEKLY",
repetition: { freq: "WEEKLY" },
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
@@ -280,7 +280,7 @@ describe("calendarEventToJCal", () => {
allday: true,
location: "Room 101",
description: "Discuss project roadmap.",
repetition: "WEEKLY",
repetition: { freq: "WEEKLY" },
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
+164
View File
@@ -0,0 +1,164 @@
import {
FormControl,
InputLabel,
Select,
SelectChangeEvent,
MenuItem,
Box,
Stack,
Paper,
Typography,
TextField,
Checkbox,
List,
ListItem,
FormControlLabel,
FormGroup,
Radio,
RadioGroup,
} from "@mui/material";
import { useState } from "react";
import { RepetitionObject } from "../../features/Events/EventsTypes";
export default function RepeatEvent({
repetition,
setRepetition,
isOwn = true,
}: {
repetition: RepetitionObject;
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 handleDayChange = (day: string) => {
setSelectedDays((prev: string[]) =>
prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day]
);
};
return (
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="repeat">Repetition</InputLabel>
<Select
labelId="repeat"
value={repetition.freq ?? ""}
disabled={!isOwn}
label="Repetition"
onChange={(e: SelectChangeEvent) =>
setRepetition({ ...repetition, freq: e.target.value })
}
>
<MenuItem value={""}>No Repetition</MenuItem>
<MenuItem value={"daily"}>Repeat daily</MenuItem>
<MenuItem value={"weekly"}>Repeat weekly</MenuItem>
<MenuItem value={"monthly"}>Repeat monthly</MenuItem>
<MenuItem value={"yearly"}>Repeat yearly</MenuItem>
</Select>
{repetition.freq && (
<Stack>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Typography>Interval:</Typography>
<TextField
type="number"
value={interval}
onChange={(e) => setInterval(Number(e.target.value))}
size="small"
sx={{ width: 80 }}
/>
<Typography>
{
repetitionValues[
repetitionValues.findIndex((el) => el === repetition.freq)
]
}
</Typography>
</Box>
{repetition.freq === "weekly" && (
<Box>
<Typography variant="body2" gutterBottom>
On days:
</Typography>
<FormGroup row>
{days.map((day) => (
<FormControlLabel
key={day}
control={
<Checkbox
checked={selectedDays.includes(day)}
onChange={() => handleDayChange(day)}
/>
}
label={day}
/>
))}
</FormGroup>
</Box>
)}
<Box>
<Typography variant="body2" gutterBottom sx={{ mt: 2 }}>
End:
</Typography>
<RadioGroup
value={endOption}
onChange={(e) => setEndOption(e.target.value)}
>
<FormControlLabel
value="never"
control={<Radio />}
label="Never"
/>
<FormControlLabel
value="after"
control={<Radio />}
label={
<Box display="flex" alignItems="center" gap={1}>
After
<TextField
type="number"
size="small"
value={occurrences}
onChange={(e) => setOccurrences(Number(e.target.value))}
sx={{ width: 100 }}
inputProps={{ min: 1 }}
disabled={endOption !== "after"}
/>
occurrences
</Box>
}
/>
<FormControlLabel
value="on"
control={<Radio />}
label={
<Box display="flex" alignItems="center" gap={1}>
On
<TextField
type="date"
size="small"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
disabled={endOption !== "on"}
/>
</Box>
}
/>
</RadioGroup>
</Box>
</Stack>
)}
</FormControl>
);
}
+1 -2
View File
@@ -74,7 +74,7 @@ export function Menubar() {
>
<div className="app-grid">
{applist.map((prop: AppIconProps) => (
<AppIcon prop={prop} />
<AppIcon key={prop.name} prop={prop} />
))}
</div>
</Popover>
@@ -96,7 +96,6 @@ export function MainTitle() {
function AppIcon({ prop }: { prop: AppIconProps }) {
return (
<a
key={prop.name}
href={prop.link}
target="_blank"
style={{ textDecoration: "none", color: "inherit" }}
+34 -1
View File
@@ -4,7 +4,7 @@ import { CalendarEvent } from "../Events/EventsTypes";
import { getCalendar, getCalendars } from "./CalendarApi";
import { getOpenPaasUser, getUserDetails } from "../User/userAPI";
import { parseCalendarEvent } from "../Events/eventUtils";
import { deleteEvent, moveEvent, putEvent } from "../Events/EventApi";
import { deleteEvent, getEvent, moveEvent, putEvent } from "../Events/EventApi";
import { formatDateToYYYYMMDDTHHMMSS } from "../../utils/dateUtils";
export const getCalendarsListAsync = createAsyncThunk<
@@ -92,6 +92,18 @@ export const putEventAsync = createAsyncThunk<
events,
};
});
export const getEventAsync = createAsyncThunk<
{ calId: string; event: CalendarEvent }, // Return type
CalendarEvent // Arg type
>("calendars/getEvent", async (event) => {
const response: CalendarEvent = await getEvent(event);
return {
calId: event.calId,
event: response,
};
});
export const moveEventAsync = createAsyncThunk<
{ calId: string; events: CalendarEvent[] }, // Return type
{ cal: Calendars; newEvent: CalendarEvent; newURL: string } // Arg type
@@ -233,6 +245,24 @@ const CalendarSlice = createSlice({
});
}
)
.addCase(
getEventAsync.fulfilled,
(
state,
action: PayloadAction<{ calId: string; event: CalendarEvent }>
) => {
state.pending = false;
if (!state.list[action.payload.calId]) {
state.list[action.payload.calId] = {
id: action.payload.calId,
events: {},
} as Calendars;
}
state.list[action.payload.calId].events[action.payload.event.uid] =
action.payload.event;
}
)
.addCase(
moveEventAsync.fulfilled,
(
@@ -266,6 +296,9 @@ const CalendarSlice = createSlice({
.addCase(getCalendarDetailAsync.pending, (state) => {
state.pending = true;
})
.addCase(getEventAsync.pending, (state) => {
state.pending = true;
})
.addCase(getCalendarsListAsync.pending, (state) => {
state.pending = true;
})
+15 -1
View File
@@ -1,6 +1,20 @@
import { api } from "../../utils/apiUtils";
import { CalendarEvent } from "./EventsTypes";
import { calendarEventToJCal } from "./eventUtils";
import { calendarEventToJCal, parseCalendarEvent } from "./eventUtils";
import ICAL from "ical.js";
export async function getEvent(event: CalendarEvent) {
const response = await api.get(`dav${event.URL}`);
const eventData = await response.text();
const eventical = ICAL.parse(eventData);
const eventjson = parseCalendarEvent(
eventical[2][1][1],
event.color ?? "",
event.calId,
event.URL
);
return { ...eventjson, ...event };
}
export async function putEvent(event: CalendarEvent) {
const response = await api(`dav${event.URL}`, {
+25 -13
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import {
deleteEventAsync,
getEventAsync,
moveEventAsync,
putEventAsync,
removeEvent,
@@ -42,10 +43,10 @@ import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { userAttendee } from "../User/userDataTypes";
import { TIMEZONES } from "../../utils/timezone-data";
import { Calendars } from "../Calendars/CalendarTypes";
import { CalendarEvent } from "./EventsTypes";
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { isValidUrl } from "../../utils/apiUtils";
import { formatLocalDateTime } from "./EventModal";
import RepeatEvent from "./EventRepeat";
import RepeatEvent from "../../components/Event/EventRepeat";
export default function EventDisplayModal({
eventId,
@@ -68,8 +69,8 @@ export default function EventDisplayModal({
const [showAllAttendees, setShowAllAttendees] = useState(false);
const [showMore, setShowMore] = useState(false);
const calendars = useAppSelector((state) =>
Object.values(state.calendars.list)
const calendars = Object.values(
useAppSelector((state) => state.calendars.list)
);
const userPersonnalCalendars: Calendars[] = calendars.filter(
@@ -87,13 +88,15 @@ export default function EventDisplayModal({
formatLocalDateTime(new Date(event?.end ?? Date.now()))
);
const [allday, setAllDay] = useState(event?.allday);
const [repetition, setRepetition] = useState(event?.repetition ?? "");
const [repetition, setRepetition] = useState<RepetitionObject>(
event.repetition ?? ({} as RepetitionObject)
);
const [alarm, setAlarm] = useState("");
const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC");
const [timezone, setTimezone] = useState(event?.timezone ?? "UTC");
const [newCalId, setNewCalId] = useState(event.calId);
const [calendarid, setCalendarid] = useState(
event?.calId.split("/")[0] === user.userData.openpaasId
calId.split("/")[0] === user.userData.openpaasId
? userPersonnalCalendars.findIndex((cal) => cal.id === calId)
: calendars.findIndex((cal) => cal.id === calId)
);
@@ -103,7 +106,6 @@ export default function EventDisplayModal({
(a) => a.cal_address !== event?.organizer?.cal_address
)
);
const currentUserAttendee = event?.attendee?.find(
(person) => person.cal_address === user.userData.email
);
@@ -120,7 +122,7 @@ export default function EventDisplayModal({
if (!event || !calendar) {
onClose({}, "backdropClick");
}
}, [event, calendar, onClose]);
}, [open, eventId, dispatch, onClose]);
if (!event || !calendar) return null;
@@ -187,8 +189,18 @@ export default function EventDisplayModal({
onClose({}, "backdropClick");
};
const [detailsLoaded, setDetailsLoaded] = useState(false);
const handleToggleShowMore = async () => {
if (!detailsLoaded) {
await dispatch(getEventAsync(event));
setDetailsLoaded(true);
}
setShowMore(!showMore);
};
const calList =
event.calId.split("/")[0] === user.userData.openpaasId
calId.split("/")[0] === user.userData.openpaasId
? Object.keys(userPersonnalCalendars).map((calendar, index) => (
<MenuItem key={index} value={index}>
<Typography variant="body2">
@@ -230,7 +242,7 @@ export default function EventDisplayModal({
<CardHeader title={isOwn ? "Edit Event" : "Event Details"} />
<CardContent sx={{ maxHeight: "85vh", overflow: "auto" }}>
<CardContent sx={{ overflow: "auto" }}>
{/* Title */}
<TextField
fullWidth
@@ -403,7 +415,7 @@ export default function EventDisplayModal({
? attendees
: attendees.slice(0, attendeeDisplayLimit)
).map((a, idx) => (
<Box>
<Box key={a.cal_address}>
{renderAttendeeBadge(a, idx.toString())}
{isOwn && (
<IconButton
@@ -491,7 +503,7 @@ export default function EventDisplayModal({
<InputLabel id="busy">is Busy</InputLabel>
<Select
labelId="busy"
value={eventClass}
value={""}
disabled={!isOwn}
label="is busy"
onChange={(e: SelectChangeEvent) =>
@@ -531,7 +543,7 @@ export default function EventDisplayModal({
<DeleteIcon fontSize="small" />
</IconButton>
)}
<Button size="small" onClick={() => setShowMore(!showMore)}>
<Button size="small" onClick={handleToggleShowMore}>
{showMore ? "Show Less" : "Show More"}
</Button>
+11 -3
View File
@@ -1,5 +1,9 @@
import { useEffect, useState } from "react";
import { deleteEventAsync, putEventAsync } from "../Calendars/CalendarSlice";
import {
deleteEventAsync,
getEventAsync,
putEventAsync,
} from "../Calendars/CalendarSlice";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import {
Popover,
@@ -32,6 +36,8 @@ import EventDisplayModal, {
renderAttendeeBadge,
stringAvatar,
} from "./EventDisplay";
import { getEvent } from "./EventApi";
import { CalendarEvent } from "./EventsTypes";
export default function EventPreviewModal({
eventId,
@@ -47,13 +53,15 @@ export default function EventPreviewModal({
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
}) {
const dispatch = useAppDispatch();
const calendar = useAppSelector((state) => state.calendars.list[calId]);
const calendars = useAppSelector((state) => state.calendars);
const calendar = calendars.list[calId];
const event = useAppSelector(
(state) => state.calendars.list[calId]?.events[eventId]
);
const user = useAppSelector((state) => state.user);
const [showAllAttendees, setShowAllAttendees] = useState(false);
const [openFullDisplay, setOpenFullDisplay] = useState(false);
useEffect(() => {
if (!event || !calendar) {
onClose({}, "backdropClick");
@@ -126,7 +134,7 @@ export default function EventPreviewModal({
<>
<IconButton
size="small"
onClick={() => {
onClick={async () => {
setOpenFullDisplay(!openFullDisplay);
}}
>
+6 -4
View File
@@ -22,9 +22,9 @@ import { TIMEZONES } from "../../utils/timezone-data";
import { putEventAsync } from "../Calendars/CalendarSlice";
import { Calendars } from "../Calendars/CalendarTypes";
import { userAttendee } from "../User/userDataTypes";
import { CalendarEvent } from "./EventsTypes";
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { createSelector } from "@reduxjs/toolkit";
import RepeatEvent from "./EventRepeat";
import RepeatEvent from "../../components/Event/EventRepeat";
function EventPopover({
anchorEl,
@@ -70,7 +70,9 @@ function EventPopover({
const [end, setEnd] = useState("");
const [calendarid, setCalendarid] = useState(0);
const [allday, setAllDay] = useState(false);
const [repetition, setRepetition] = useState("");
const [repetition, setRepetition] = useState<RepetitionObject>(
{} as RepetitionObject
);
const [attendees, setAttendees] = useState<userAttendee[]>([]);
const [alarm, setAlarm] = useState("");
const [eventClass, setEventClass] = useState("PUBLIC");
@@ -343,7 +345,7 @@ function EventPopover({
<InputLabel id="busy">is Busy</InputLabel>
<Select
labelId="busy"
value={eventClass}
value={""}
label="is busy"
onChange={(e: SelectChangeEvent) =>
console.log(e.target.value)
-36
View File
@@ -1,36 +0,0 @@
import {
FormControl,
InputLabel,
Select,
SelectChangeEvent,
MenuItem,
} from "@mui/material";
export default function RepeatEvent({
repetition,
setRepetition,
isOwn = true,
}: {
repetition: string;
setRepetition: Function;
isOwn?: boolean;
}) {
return (
<FormControl fullWidth margin="dense" size="small">
<InputLabel id="repeat">Repetition</InputLabel>
<Select
labelId="repeat"
value={repetition}
disabled={!isOwn}
label="Repetition"
onChange={(e: SelectChangeEvent) => setRepetition(e.target.value)}
>
<MenuItem value={""}>No Repetition</MenuItem>
<MenuItem value={"daily"}>Repeat daily</MenuItem>
<MenuItem value={"weekly"}>Repeat weekly</MenuItem>
<MenuItem value={"monthly"}>Repeat monthly</MenuItem>
<MenuItem value={"yearly"}>Repeat yearly</MenuItem>
</Select>
</FormControl>
);
}
+9 -1
View File
@@ -21,5 +21,13 @@ export interface CalendarEvent {
error?: string;
status?: string;
timezone: string;
repetition?: string;
repetition?: RepetitionObject;
}
export interface RepetitionObject {
freq: string;
interval?: number;
selectedDays?: string[];
occurrences?: number;
endDate?: string;
}
+32 -3
View File
@@ -81,6 +81,22 @@ export function parseCalendarEvent(
break;
case "status":
event.status = String(value);
break;
case "rrule":
event.repetition = { freq: value.freq.toLowerCase() };
if (value.byday) {
event.repetition.selectedDays = value.byday;
}
if (value.until) {
event.repetition.selectedDays = value.endDate;
}
if (value.count) {
event.repetition.selectedDays = value.occurrences;
}
if (value.interval) {
event.repetition.interval = value.interval;
}
break;
}
}
if (recurrenceId && event.uid) {
@@ -105,7 +121,7 @@ export function calendarEventToJCal(event: CalendarEvent): any[] {
const vevent: any[] = [
"vevent",
[
["uid", {}, "text", event.uid],
["uid", {}, "text", event.uid.split("/")[0]],
["transp", {}, "text", event.transp ?? "OPAQUE"],
[
"dtstart",
@@ -150,8 +166,21 @@ export function calendarEventToJCal(event: CalendarEvent): any[] {
if (event.description) {
vevent[1].push(["description", {}, "text", event.description]);
}
if (event.repetition) {
vevent[1].push(["rrule", {}, "recur", { freq: event.repetition }]);
if (event.repetition?.freq) {
const repetitionRule: Record<string, any> = { freq: event.repetition.freq };
if (event.repetition.interval) {
repetitionRule["interval"] = event.repetition.interval;
}
if (event.repetition.occurrences) {
repetitionRule["count"] = event.repetition.occurrences;
}
if (event.repetition.endDate) {
repetitionRule["until"] = event.repetition.endDate;
}
if (event.repetition.selectedDays) {
repetitionRule["byday"] = event.repetition.selectedDays;
}
vevent[1].push(["rrule", {}, "recur", repetitionRule]);
}
event.attendee.forEach((att) => {