From e8a0ac41ef06376e98eccfa4fa216de503e7e4c5 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Mon, 25 Aug 2025 17:01:18 +0200 Subject: [PATCH 01/16] [#7] skeleton for complexe display --- .../features/Events/EventDisplay.test.tsx | 64 ++- src/components/Calendar/Calendar.tsx | 19 +- src/features/Calendars/CalendarSlice.ts | 4 +- src/features/Calendars/CalendarTypes.ts | 1 + src/features/Events/EventDisplay.tsx | 506 ++++++++++++++---- src/features/Events/EventDisplayPreview.tsx | 414 ++++++++++++++ 6 files changed, 864 insertions(+), 144 deletions(-) create mode 100644 src/features/Events/EventDisplayPreview.tsx diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index 01edee8..2a368a8 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -5,7 +5,7 @@ import EventPopover from "../../../src/features/Events/EventModal"; import { DateSelectArg } from "@fullcalendar/core"; import preview from "jest-preview"; import { formatDateToYYYYMMDDTHHMMSS } from "../../../src/utils/dateUtils"; -import EventDisplayModal from "../../../src/features/Events/EventDisplay"; +import EventPreviewModal from "../../../src/features/Events/EventDisplayPreview"; describe("Event Display", () => { const mockOnClose = jest.fn(); @@ -39,8 +39,28 @@ describe("Event Display", () => { event1: { id: "event1", title: "Test Event", + calId: "667037022b752d0026472254/cal1", start: day.toISOString(), end: day.toISOString(), + organizer: { cn: "test", cal_address: "test@test.com" }, + attendee: [ + { + cn: "test", + cal_address: "test@test.com", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + }, + { + cn: "John", + cal_address: "john@test.com", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + }, + ], }, }, }, @@ -51,9 +71,11 @@ describe("Event Display", () => { events: { event1: { id: "event1", + calId: "otherCal/cal", title: "Test Event Other cal", start: day.toISOString(), end: day.toISOString(), + organizer: { cn: "john", cal_address: "john@test.com" }, }, }, }, @@ -73,8 +95,8 @@ describe("Event Display", () => { return RealDateToLocaleString.call(this, "en-UK", options); }); renderWithProviders( - { }); it("calls onClose when Cancel clicked", () => { renderWithProviders( - { it("Shows delete button only when calendar is own", () => { // Renders the other cal event renderWithProviders( - { expect(screen.queryByTestId("DeleteIcon")).not.toBeInTheDocument(); // Renders the personnal cal event renderWithProviders( - { }); it("calls delete when Delete clicked", async () => { renderWithProviders( - { }; renderWithProviders( - { }; renderWithProviders( - { }; renderWithProviders( - { }; renderWithProviders( - { }; renderWithProviders( - (null); - const [anchorElEventDisplay, setAnchorElEventDisplay] = - useState(null); + const [anchorPosition, setAnchorPosition] = useState<{ + top: number; + left: number; + } | null>(null); const [openEventDisplay, setOpenEventDisplay] = useState(false); const [eventDisplayedId, setEventDisplayedId] = useState(""); const [eventDisplayedCalId, setEventDisplayedCalId] = useState(""); @@ -130,7 +132,7 @@ export default function CalendarApp() { setSelectedRange(null); }; const handleCloseEventDisplay = () => { - setAnchorElEventDisplay(null); + setAnchorPosition(null); setOpenEventDisplay(false); }; @@ -316,7 +318,10 @@ export default function CalendarApp() { } else { console.log(info.event); setOpenEventDisplay(true); - setAnchorElEventDisplay(info.el); + setAnchorPosition({ + top: info.jsEvent.clientY, + left: info.jsEvent.clientX, + }); setEventDisplayedId(info.event.extendedProps.uid); setEventDisplayedCalId(info.event.extendedProps.calId); } @@ -468,10 +473,10 @@ export default function CalendarApp() { onClose={() => setAnchorElCal(null)} /> {openEventDisplay && eventDisplayedId && eventDisplayedCalId && ( - diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 1c165fe..b7435e2 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -26,7 +26,6 @@ export const getCalendarsListAsync = createAsyncThunk< source = cal["calendarserver:delegatedsource"]; delegated = true; } - console.log(source); const id = source.replace("/calendars/", "").replace(".json", ""); const ownerData: any = await getUserDetails(id.split("/")[0]); @@ -34,6 +33,9 @@ export const getCalendarsListAsync = createAsyncThunk< importedCalendars[id] = { id, name, + owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ + ownerData.lastname + }`, ownerEmails: ownerData.emails, description, delegated, diff --git a/src/features/Calendars/CalendarTypes.ts b/src/features/Calendars/CalendarTypes.ts index 70fcf3d..f62d4ee 100644 --- a/src/features/Calendars/CalendarTypes.ts +++ b/src/features/Calendars/CalendarTypes.ts @@ -7,6 +7,7 @@ export interface Calendars { prodid?: string; color?: string; ownerEmails?: string[]; + owner: string; description?: string; calscale?: string; version?: string; diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 01c8de6..5fe9b1f 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -13,6 +13,18 @@ import { IconButton, Avatar, Badge, + PopoverPosition, + Modal, + TextField, + CardHeader, + FormControl, + InputLabel, + MenuItem, + Select, + SelectChangeEvent, + Checkbox, + FormControlLabel, + CardActions, } from "@mui/material"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; @@ -25,18 +37,21 @@ import CircleIcon from "@mui/icons-material/Circle"; import CancelIcon from "@mui/icons-material/Cancel"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import { userAttendee } from "../User/userDataTypes"; +import timezone from "ical.js/dist/types/timezone"; +import { title } from "process"; +import { start } from "repl"; +import { TIMEZONES } from "../../utils/timezone-data"; +import { Calendars } from "../Calendars/CalendarTypes"; import { putEvent } from "./EventApi"; function EventDisplayModal({ eventId, calId, - anchorEl, open, onClose, }: { eventId: string; calId: string; - anchorEl: HTMLElement | null; open: boolean; onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void; }) { @@ -47,7 +62,39 @@ function EventDisplayModal({ ); const user = useAppSelector((state) => state.user); const [showAllAttendees, setShowAllAttendees] = useState(false); - + const calendars = useAppSelector((state) => + Object.keys(state.calendars.list).map((id) => state.calendars.list[id]) + ); + const userPersonnalCalendars: Calendars[] = useAppSelector((state) => + Object.keys(state.calendars.list).map((id) => { + if (id.split("/")[0] === user.userData.openpaasId) { + return state.calendars.list[id]; + } + return {} as Calendars; + }) + ).filter((calendar) => calendar.id); + const timezones = TIMEZONES.aliases; + const [title, setTitle] = useState(event.title); + const [description, setDescription] = useState(event.description); + const [location, setLocation] = useState(event.location); + const [start, setStart] = useState(new Date(event.start)); + const [end, setEnd] = useState(new Date(event.end ?? "")); + const [calendarid, setCalendarid] = useState( + event.calId.split("/")[0] === user.userData.openpaasId + ? userPersonnalCalendars.findIndex((cal) => cal.id === calId) + : calendars.findIndex((cal) => cal.id === calId) + ); + const [allday, setAllDay] = useState(event.allday); + const [repetition, setRepetition] = useState(event.repetition ?? ""); + const [alarm, setAlarm] = useState(""); + const [eventClass, setEventClass] = useState(event.class); + const [selectedRange, setSelectedRange] = useState({ + start: new Date(event.start), + end: new Date(event.end ?? ""), + allday: event.allday, + }); + const [timezone, setTimezone] = useState(event.timezone); + const [showMore, setShowMore] = useState(false); useEffect(() => { if (!event || !calendar) { onClose({}, "backdropClick"); @@ -87,9 +134,49 @@ function EventDisplayModal({ onClose({}, "backdropClick"); } + const isOwn = organizer?.cal_address === user.userData.email; + + const calList = + event.calId.split("/")[0] === user.userData.openpaasId + ? Object.keys(userPersonnalCalendars).map((calendar, index) => ( + + + + {userPersonnalCalendars[index].name} + + + )) + : Object.keys(calendars).map((calendar, index) => ( + + + + {calendars[index].name} - {calendars[index].owner} + + + )); + return ( - - + + {/* Top-right buttons */} - - - - {calendar.id.split("/")[0] === user.userData.openpaasId && ( - { - onClose({}, "backdropClick"); - dispatch(deleteEventAsync({ calId, eventId })); - }} - > - - - )} onClose({}, "backdropClick")}> - - - {event.title && ( - - {event.title} - + + + setTitle(e.target.value)} + size="small" + margin="dense" + /> + {/* RSVP */} + {currentUserAttendee && ( + + + + + + + + + + )} - - {/* Time info*/} - - {formatDate(event.start)} - {event.end && - ` – ${new Date(event.end).toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - })}`} - - - {/* Location */} - {event.location && ( - } - text={event.location} - /> - )} - + + Calendar + + + { + const newStart = e.target.value; + // setStart(newStart); + // const newRange = { + // ...selectedRange, + // start: new Date(newStart), + // startStr: newStart, + // allDay: allday, + // }; + // setSelectedRange(newRange); + // calendarRef.current?.select(newRange); + }} + size="small" + margin="dense" + InputLabelProps={{ shrink: true }} + /> + { + const newEnd = e.target.value; + // setEnd(newEnd); + // const newRange = { + // ...selectedRange, + // end: new Date(newEnd), + // endStr: newEnd, + // allDay: allday, + // }; + // setSelectedRange(newRange); + // calendarRef.current?.select(newRange); + }} + size="small" + margin="dense" + InputLabelProps={{ shrink: true }} + />{" "} + { + setAllDay(!allday); + // const newRange = { + // startStr: allday ? start.split("T")[0] : start, + // endStr: allday ? end.split("T")[0] : end, + // start: new Date(allday ? start.split("T")[0] : start), + // end: new Date(allday ? end.split("T")[0] : end), + // allday, + // ...selectedRange, + // }; + // setSelectedRange(newRange); + // calendarRef.current?.select(newRange); + }} + /> + } + label="All day" + /> + setDescription(e.target.value)} + size="small" + margin="dense" + multiline + rows={2} + /> + setLocation(e.target.value)} + size="small" + margin="dense" + /> {/* Video */} {event.x_openpass_videoconference && ( )} - {/* Attendees */} {event.attendee?.length > 0 && ( @@ -164,7 +375,11 @@ function EventDisplayModal({ setShowAllAttendees(!showAllAttendees)} > {showAllAttendees @@ -176,81 +391,142 @@ function EventDisplayModal({ )} )} - - {/* Error */} - {event.error && ( - } - text={event.error} - error - /> - )} - - {/* Calendar color dot */} - - - - {calendar.name} - - - - {/* RSVP */} - {currentUserAttendee && ( - - - Will you attend? - - - - - - - - )} - {/* Description */} {event.description && ( {event.description} )} + {showMore && ( + <> + + Repetition + + + + Alarm + + + + Repetition + + + + class + + + + is Busy + + + {/* Error */} + {event.error && ( + + } + text={event.error} + error + /> + )} + + )} + + + { + onClose({}, "backdropClick"); + dispatch( + deleteEventAsync({ calId, eventId, eventURL: event.URL }) + ); + }} + > + + + + + - + ); } @@ -278,7 +554,7 @@ function renderAttendeeBadge( key: string, isOrganizer?: boolean ) { - const statusIcon = + const classIcon = a.partstat === "ACCEPTED" ? ( ) : a.partstat === "DECLINED" ? ( @@ -301,7 +577,7 @@ function renderAttendeeBadge( overlap="circular" anchorOrigin={{ vertical: "bottom", horizontal: "left" }} badgeContent={ - statusIcon && ( + classIcon && ( - {statusIcon} + {classIcon} ) } diff --git a/src/features/Events/EventDisplayPreview.tsx b/src/features/Events/EventDisplayPreview.tsx new file mode 100644 index 0000000..63ff084 --- /dev/null +++ b/src/features/Events/EventDisplayPreview.tsx @@ -0,0 +1,414 @@ +import { useEffect, useState } from "react"; +import { deleteEventAsync, putEventAsync } from "../Calendars/CalendarSlice"; +import { useAppDispatch, useAppSelector } from "../../app/hooks"; +import { + Popover, + Button, + Box, + Typography, + ButtonGroup, + Card, + CardContent, + Divider, + IconButton, + Avatar, + Badge, + PopoverPosition, +} from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import CloseIcon from "@mui/icons-material/Close"; +import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; +import LocationOnIcon from "@mui/icons-material/LocationOn"; +import VideocamIcon from "@mui/icons-material/Videocam"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; +import CircleIcon from "@mui/icons-material/Circle"; +import CancelIcon from "@mui/icons-material/Cancel"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import { userAttendee } from "../User/userDataTypes"; +import EventDisplayModal from "./EventDisplay"; + +export default function EventPreviewModal({ + eventId, + calId, + anchorPosition, + open, + onClose, +}: { + eventId: string; + calId: string; + anchorPosition: PopoverPosition | null; + open: boolean; + onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void; +}) { + const dispatch = useAppDispatch(); + const calendar = useAppSelector((state) => state.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"); + } + }, [event, calendar, onClose]); + + if (!event || !calendar) return null; + + const attendeeDisplayLimit = 3; + + const attendees = + event.attendee?.filter( + (a) => a.cal_address !== event.organizer?.cal_address + ) || []; + + const visibleAttendees = showAllAttendees + ? attendees + : attendees.slice(0, attendeeDisplayLimit); + + const currentUserAttendee = event.attendee?.find( + (person) => person.cal_address === user.userData.email + ); + + const organizer = event.attendee?.find( + (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 ( + <> + + + {/* Top-right buttons */} + + {user.userData.email !== event.organizer?.cal_address && ( + { + setOpenFullDisplay(!openFullDisplay); + }} + > + + + )} + {user.userData.email === event.organizer?.cal_address && ( + <> + { + setOpenFullDisplay(!openFullDisplay); + }} + > + + + { + onClose({}, "backdropClick"); + dispatch( + deleteEventAsync({ calId, eventId, eventURL: event.URL }) + ); + }} + > + + + + )} + onClose({}, "backdropClick")} + > + + + + + + {event.title && ( + + {event.title} + + )} + + {/* Time info*/} + + {formatDate(event.start)} + {event.end && + ` – ${new Date(event.end).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + })}`} + + + {/* Location */} + {event.location && ( + } + text={event.location} + /> + )} + + {/* Video */} + {event.x_openpass_videoconference && ( + } + text="Video conference available" + /> + )} + + {/* Attendees */} + {event.attendee?.length > 0 && ( + + Attendees: + {organizer && renderAttendeeBadge(organizer, "org", true)} + {visibleAttendees.map((a, idx) => + renderAttendeeBadge(a, idx.toString()) + )} + {attendees.length > attendeeDisplayLimit && ( + setShowAllAttendees(!showAllAttendees)} + > + {showAllAttendees + ? "Show less" + : `Show more (${ + attendees.length - attendeeDisplayLimit + } more)`} + + )} + + )} + + {/* Error */} + {event.error && ( + } + text={event.error} + error + /> + )} + + {/* Calendar color dot */} + + + + {calendar.name} + + + + + {/* RSVP */} + {currentUserAttendee && ( + + + Will you attend? + + + + + + + + )} + + {/* Description */} + {event.description && ( + + {event.description} + + )} + + + + setOpenFullDisplay(false)} + eventId={eventId} + calId={calId} + /> + + ); +} + +function InfoRow({ + icon, + text, + error = false, +}: { + icon: React.ReactNode; + text: string; + error?: boolean; +}) { + return ( + + {icon} + + {text} + + + ); +} + +function renderAttendeeBadge( + a: userAttendee, + key: string, + isOrganizer?: boolean +) { + const statusIcon = + a.partstat === "ACCEPTED" ? ( + + ) : a.partstat === "DECLINED" ? ( + + ) : null; + + return ( + + + {statusIcon} + + ) + } + > + + + + + {a.cn || a.cal_address} + + {isOrganizer && ( + + Organizer + + )} + + + ); +} + +function formatDate(date: Date) { + return new Date(date).toLocaleString(undefined, { + weekday: "long", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export function stringToColor(string: string) { + let hash = 0; + for (let i = 0; i < string.length; i++) { + hash = string.charCodeAt(i) + ((hash << 5) - hash); + } + + let color = "#"; + for (let i = 0; i < 3; i++) { + const value = (hash >> (i * 8)) & 0xff; + color += `00${value.toString(16)}`.slice(-2); + } + + return color; +} + +function stringAvatar(name: string) { + return { + sx: { width: 24, height: 24, fontSize: 18, bgcolor: stringToColor(name) }, + children: name[0], + }; +} From 502b6d6783fa128b436bf7458b77db91dea97823 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Tue, 26 Aug 2025 14:17:14 +0200 Subject: [PATCH 02/16] [#7] added saving to editing event --- src/features/Calendars/CalendarSlice.ts | 1 + src/features/Events/EventDisplay.tsx | 433 +++++++++----------- src/features/Events/EventDisplayPreview.tsx | 119 +----- src/features/Events/EventModal.tsx | 7 +- src/features/Events/EventsTypes.ts | 2 +- src/utils/apiUtils.ts | 11 + 6 files changed, 229 insertions(+), 344 deletions(-) diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index b7435e2..3baebfe 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -77,6 +77,7 @@ export const putEventAsync = createAsyncThunk< new Date(new Date(newEvent.start).getTime() + 86400000) ), })) as Record; + console.log(calEvents) const events: CalendarEvent[] = calEvents._embedded["dav:item"].flatMap( (eventdata: any) => { const vevents = eventdata.data[2] as any[][]; diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 5fe9b1f..ec4d62e 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -13,7 +13,6 @@ import { IconButton, Avatar, Badge, - PopoverPosition, Modal, TextField, CardHeader, @@ -25,26 +24,23 @@ import { Checkbox, FormControlLabel, CardActions, + Link, } from "@mui/material"; -import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; import CloseIcon from "@mui/icons-material/Close"; -import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; -import LocationOnIcon from "@mui/icons-material/LocationOn"; import VideocamIcon from "@mui/icons-material/Videocam"; import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; import CircleIcon from "@mui/icons-material/Circle"; import CancelIcon from "@mui/icons-material/Cancel"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import { userAttendee } from "../User/userDataTypes"; -import timezone from "ical.js/dist/types/timezone"; -import { title } from "process"; -import { start } from "repl"; import { TIMEZONES } from "../../utils/timezone-data"; import { Calendars } from "../Calendars/CalendarTypes"; -import { putEvent } from "./EventApi"; +import { CalendarEvent } from "./EventsTypes"; +import { isValidUrl } from "../../utils/apiUtils"; +import { formatLocalDateTime } from "./EventModal"; -function EventDisplayModal({ +export default function EventDisplayModal({ eventId, calId, open, @@ -61,40 +57,58 @@ function EventDisplayModal({ (state) => state.calendars.list[calId]?.events[eventId] ); const user = useAppSelector((state) => state.user); + const [showAllAttendees, setShowAllAttendees] = useState(false); + const [showMore, setShowMore] = useState(false); + const calendars = useAppSelector((state) => - Object.keys(state.calendars.list).map((id) => state.calendars.list[id]) + Object.values(state.calendars.list) ); - const userPersonnalCalendars: Calendars[] = useAppSelector((state) => - Object.keys(state.calendars.list).map((id) => { - if (id.split("/")[0] === user.userData.openpaasId) { - return state.calendars.list[id]; - } - return {} as Calendars; - }) - ).filter((calendar) => calendar.id); - const timezones = TIMEZONES.aliases; - const [title, setTitle] = useState(event.title); - const [description, setDescription] = useState(event.description); - const [location, setLocation] = useState(event.location); - const [start, setStart] = useState(new Date(event.start)); - const [end, setEnd] = useState(new Date(event.end ?? "")); + + const userPersonnalCalendars: Calendars[] = calendars.filter( + (c) => c.id?.split("/")[0] === user.userData.openpaasId + ); + + // Form state + const [title, setTitle] = useState(event?.title ?? ""); + const [description, setDescription] = useState(event?.description ?? ""); + const [location, setLocation] = useState(event?.location ?? ""); + const [start, setStart] = useState( + formatLocalDateTime(new Date(event?.start ?? Date.now())) + ); + const [end, setEnd] = useState( + formatLocalDateTime(new Date(event?.end ?? Date.now())) + ); + const [allday, setAllDay] = useState(event?.allday); + const [repetition, setRepetition] = useState(event?.repetition ?? ""); + const [alarm, setAlarm] = useState(""); + const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC"); + const [timezone, setTimezone] = useState(event?.timezone ?? "UTC"); + const [calendarid, setCalendarid] = useState( - event.calId.split("/")[0] === user.userData.openpaasId + event?.calId.split("/")[0] === user.userData.openpaasId ? userPersonnalCalendars.findIndex((cal) => cal.id === calId) : calendars.findIndex((cal) => cal.id === calId) ); - const [allday, setAllDay] = useState(event.allday); - const [repetition, setRepetition] = useState(event.repetition ?? ""); - const [alarm, setAlarm] = useState(""); - const [eventClass, setEventClass] = useState(event.class); - const [selectedRange, setSelectedRange] = useState({ - start: new Date(event.start), - end: new Date(event.end ?? ""), - allday: event.allday, - }); - const [timezone, setTimezone] = useState(event.timezone); - const [showMore, setShowMore] = useState(false); + + const [attendees, setAttendees] = useState( + (event?.attendee || []).filter( + (a) => a.cal_address !== event?.organizer?.cal_address + ) + ); + + const currentUserAttendee = event?.attendee?.find( + (person) => person.cal_address === user.userData.email + ); + + const organizer = event?.attendee?.find( + (a) => a.cal_address === event?.organizer?.cal_address + ); + + const isOwn = organizer?.cal_address === user.userData.email; + const isOwnCal = userPersonnalCalendars.find((cal) => cal.id === calId); + const attendeeDisplayLimit = 3; + useEffect(() => { if (!event || !calendar) { onClose({}, "backdropClick"); @@ -103,25 +117,6 @@ function EventDisplayModal({ if (!event || !calendar) return null; - const attendeeDisplayLimit = 3; - - const attendees = - event.attendee?.filter( - (a) => a.cal_address !== event.organizer?.cal_address - ) || []; - - const visibleAttendees = showAllAttendees - ? attendees - : attendees.slice(0, attendeeDisplayLimit); - - const currentUserAttendee = event.attendee?.find( - (person) => person.cal_address === user.userData.email - ); - - const organizer = event.attendee?.find( - (a) => a.cal_address === event.organizer?.cal_address - ); - function handleRSVP(rsvp: string) { const newEvent = { ...event, @@ -134,7 +129,45 @@ function EventDisplayModal({ onClose({}, "backdropClick"); } - const isOwn = organizer?.cal_address === user.userData.email; + const handleSave = () => { + const newEventUID = crypto.randomUUID(); + + const newEvent: CalendarEvent = { + calId, + title, + URL: event.URL ?? `/calendars/${calId}/${newEventUID}.ics`, + start: new Date(start), + end: new Date(end), + allday, + uid: event.uid ?? newEventUID, + description, + location, + repetition, + organizer: event.organizer, + timezone, + attendee: [ + { + cn: event.organizer?.cn, + cal_address: event.organizer?.cal_address ?? "", + partstat: "ACCEPTED", + rsvp: "FALSE", + role: "CHAIR", + cutype: "INDIVIDUAL", + }, + ...attendees, + ], + transp: "OPAQUE", + color: userPersonnalCalendars[calendarid]?.color, + }; + + dispatch( + putEventAsync({ + cal: userPersonnalCalendars[calendarid], + newEvent, + }) + ); + onClose({}, "backdropClick"); + }; const calList = event.calId.split("/")[0] === user.userData.openpaasId @@ -169,31 +202,18 @@ function EventDisplayModal({ return ( - - {/* Top-right buttons */} - + + {/* Close button */} + onClose({}, "backdropClick")}> + - + + + {/* Title */} + {/* RSVP */} - {currentUserAttendee && ( - - - - - - - - - + {currentUserAttendee && isOwnCal && ( + + + + + + + )} + + {/* Calendar selector */} Calendar setRepetition(e.target.value) } @@ -418,13 +418,13 @@ function EventDisplayModal({ Repeat yearly + - Alarm + Alarm + - Repetition - - - - class + Class @@ -484,7 +464,7 @@ function EventDisplayModal({ labelId="busy" value={eventClass} disabled={!isOwn} - label="busy" + label="is busy" onChange={(e: SelectChangeEvent) => setEventClass(e.target.value) } @@ -506,6 +486,7 @@ function EventDisplayModal({ )} + + + + {isOwn && ( + + )} @@ -530,26 +517,28 @@ function EventDisplayModal({ ); } -function InfoRow({ +export function InfoRow({ icon, text, error = false, + data, }: { icon: React.ReactNode; text: string; error?: boolean; + data?: string; }) { return ( {icon} - {text} + {isValidUrl(data) && {text}} ); } -function renderAttendeeBadge( +export function renderAttendeeBadge( a: userAttendee, key: string, isOrganizer?: boolean @@ -620,16 +609,6 @@ function renderAttendeeBadge( ); } -function formatDate(date: Date) { - return new Date(date).toLocaleString(undefined, { - weekday: "long", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} - export function stringToColor(string: string) { let hash = 0; for (let i = 0; i < string.length; i++) { @@ -645,11 +624,9 @@ export function stringToColor(string: string) { return color; } -function stringAvatar(name: string) { +export function stringAvatar(name: string) { return { sx: { width: 24, height: 24, fontSize: 18, bgcolor: stringToColor(name) }, children: name[0], }; } - -export default EventDisplayModal; diff --git a/src/features/Events/EventDisplayPreview.tsx b/src/features/Events/EventDisplayPreview.tsx index 63ff084..78fea0d 100644 --- a/src/features/Events/EventDisplayPreview.tsx +++ b/src/features/Events/EventDisplayPreview.tsx @@ -27,7 +27,11 @@ import CircleIcon from "@mui/icons-material/Circle"; import CancelIcon from "@mui/icons-material/Cancel"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import { userAttendee } from "../User/userDataTypes"; -import EventDisplayModal from "./EventDisplay"; +import EventDisplayModal, { + InfoRow, + renderAttendeeBadge, + stringAvatar, +} from "./EventDisplay"; export default function EventPreviewModal({ eventId, @@ -179,6 +183,7 @@ export default function EventPreviewModal({ } text="Video conference available" + data={event.x_openpass_videoconference} /> )} @@ -291,96 +296,6 @@ export default function EventPreviewModal({ ); } -function InfoRow({ - icon, - text, - error = false, -}: { - icon: React.ReactNode; - text: string; - error?: boolean; -}) { - return ( - - {icon} - - {text} - - - ); -} - -function renderAttendeeBadge( - a: userAttendee, - key: string, - isOrganizer?: boolean -) { - const statusIcon = - a.partstat === "ACCEPTED" ? ( - - ) : a.partstat === "DECLINED" ? ( - - ) : null; - - return ( - - - {statusIcon} - - ) - } - > - - - - - {a.cn || a.cal_address} - - {isOrganizer && ( - - Organizer - - )} - - - ); -} - function formatDate(date: Date) { return new Date(date).toLocaleString(undefined, { weekday: "long", @@ -390,25 +305,3 @@ function formatDate(date: Date) { minute: "2-digit", }); } - -export function stringToColor(string: string) { - let hash = 0; - for (let i = 0; i < string.length; i++) { - hash = string.charCodeAt(i) + ((hash << 5) - hash); - } - - let color = "#"; - for (let i = 0; i < 3; i++) { - const value = (hash >> (i * 8)) & 0xff; - color += `00${value.toString(16)}`.slice(-2); - } - - return color; -} - -function stringAvatar(name: string) { - return { - sx: { width: 24, height: 24, fontSize: 18, bgcolor: stringToColor(name) }, - children: name[0], - }; -} diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index c269402..4ad1fa0 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -78,12 +78,15 @@ function EventPopover({ }, [selectedRange]); const handleSave = async () => { + const newEventUID = crypto.randomUUID(); + const newEvent: CalendarEvent = { calId: userPersonnalCalendars[calendarid].id, title, + URL: `/calendars/${userPersonnalCalendars[calendarid].id}/${newEventUID}.ics`, start: new Date(start), allday, - uid: crypto.randomUUID(), + uid: newEventUID, description, location, repetition, @@ -295,7 +298,7 @@ function EventPopover({ export default EventPopover; -function formatLocalDateTime(date: Date): string { +export function formatLocalDateTime(date: Date): string { const pad = (n: number) => n.toString().padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad( date.getDate() diff --git a/src/features/Events/EventsTypes.ts b/src/features/Events/EventsTypes.ts index 7f4c744..22c68b9 100644 --- a/src/features/Events/EventsTypes.ts +++ b/src/features/Events/EventsTypes.ts @@ -17,7 +17,7 @@ export interface CalendarEvent { stamp?: Date; sequence?: Number; color?: string; - allday?: Boolean; + allday?: boolean; error?: string; status?: string; timezone: string; diff --git a/src/utils/apiUtils.ts b/src/utils/apiUtils.ts index 924af3b..908d8c6 100644 --- a/src/utils/apiUtils.ts +++ b/src/utils/apiUtils.ts @@ -43,3 +43,14 @@ export function redirectTo(url: URL) { export function getLocation() { return window.location.href; } + +export function isValidUrl(string: string) { + let url; + + try { + url = new URL(string); + } catch (_) { + return false; + } + return url; +} From 5864875775da7a21dd707d0a0b6c9f31bc994ce3 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Tue, 26 Aug 2025 14:46:10 +0200 Subject: [PATCH 03/16] [#7] restored refresh after modification --- src/features/Calendars/CalendarSlice.ts | 1 - src/features/Events/EventApi.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 3baebfe..b7435e2 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -77,7 +77,6 @@ export const putEventAsync = createAsyncThunk< new Date(new Date(newEvent.start).getTime() + 86400000) ), })) as Record; - console.log(calEvents) const events: CalendarEvent[] = calEvents._embedded["dav:item"].flatMap( (eventdata: any) => { const vevents = eventdata.data[2] as any[][]; diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index 2b211ad..04616de 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -13,7 +13,7 @@ export async function putEvent(event: CalendarEvent) { if (response.status === 201) { console.log("PUT (201) :", response.url); } - return await response.json(); + return response; } export async function deleteEvent(eventURL: string) { From 07a17af478f48a3fd2edb7dc4fe95649707f3b89 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Tue, 26 Aug 2025 15:46:27 +0200 Subject: [PATCH 04/16] [#7] added selector to add attendee when editing --- src/features/Events/EventDisplay.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index ec4d62e..c93a635 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { deleteEventAsync, putEventAsync } from "../Calendars/CalendarSlice"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; +import AttendeeSelector from "../../components/Attendees/AttendeeSearch"; import { Popover, Button, @@ -143,6 +144,7 @@ export default function EventDisplayModal({ description, location, repetition, + class: eventClass, organizer: event.organizer, timezone, attendee: [ @@ -337,6 +339,8 @@ export default function EventDisplayModal({ rows={2} /> + + Date: Tue, 26 Aug 2025 16:46:03 +0200 Subject: [PATCH 05/16] [#7] added missing complex params to event creation --- src/features/Events/EventDisplay.tsx | 25 +- src/features/Events/EventModal.tsx | 368 ++++++++++++++++----------- src/features/Events/EventRepeat.tsx | 30 +++ 3 files changed, 257 insertions(+), 166 deletions(-) create mode 100644 src/features/Events/EventRepeat.tsx diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index c93a635..036ecdd 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -40,6 +40,7 @@ import { Calendars } from "../Calendars/CalendarTypes"; import { CalendarEvent } from "./EventsTypes"; import { isValidUrl } from "../../utils/apiUtils"; import { formatLocalDateTime } from "./EventModal"; +import RepeatEvent from "./EventRepeat"; export default function EventDisplayModal({ eventId, @@ -405,23 +406,10 @@ export default function EventDisplayModal({ {/* Extended options */} {showMore && ( <> - - Repetition - - + Alarm @@ -451,6 +439,7 @@ export default function EventDisplayModal({ Class - is Busy + is Busy + setCalendarid(Number(e.target.value)) + } + > + {Object.keys(userPersonnalCalendars).map((calendar, index) => ( + + {userPersonnalCalendars[index].name} + + ))} + + - setTitle(e.target.value)} - size="small" - margin="dense" - /> - - Calendar - - - - { - const newStart = e.target.value; - setStart(newStart); - const newRange = { - ...selectedRange, - start: new Date(newStart), - startStr: newStart, - allDay: allday, - }; - setSelectedRange(newRange); - calendarRef.current?.select(newRange); - }} - size="small" - margin="dense" - InputLabelProps={{ shrink: true }} - /> - { - const newEnd = e.target.value; - setEnd(newEnd); - const newRange = { - ...selectedRange, - end: new Date(newEnd), - endStr: newEnd, - allDay: allday, - }; - setSelectedRange(newRange); - calendarRef.current?.select(newRange); - }} - size="small" - margin="dense" - InputLabelProps={{ shrink: true }} - /> - - setDescription(e.target.value)} - size="small" - margin="dense" - multiline - rows={2} - /> - setLocation(e.target.value)} - size="small" - margin="dense" - /> + { + const newEnd = e.target.value; + setEnd(newEnd); + const newRange = { + ...selectedRange, + end: new Date(newEnd), + endStr: newEnd, + allDay: allday, + }; + setSelectedRange(newRange); + calendarRef.current?.select(newRange); + }} + size="small" + margin="dense" + InputLabelProps={{ shrink: true }} + /> + + setDescription(e.target.value)} + size="small" + margin="dense" + multiline + rows={2} + /> + setLocation(e.target.value)} + size="small" + margin="dense" + /> + {/* Extended options */} + {showMore && ( + <> + + Repetition + + + + + Time Zone + + + + Alarm + + - - Repetition - - - - - Time Zone - - + + Class + + + + + )} + - - - - - + + + + + + + + ); } diff --git a/src/features/Events/EventRepeat.tsx b/src/features/Events/EventRepeat.tsx new file mode 100644 index 0000000..5c2db9f --- /dev/null +++ b/src/features/Events/EventRepeat.tsx @@ -0,0 +1,30 @@ +import { + FormControl, + InputLabel, + Select, + SelectChangeEvent, + MenuItem, +} from "@mui/material"; + +export default function RepeatEvent({ + eventClass, + setEventClass, +}: { + eventClass: string; + setEventClass: React.Dispatch>; +}) { + return ( + + is Busy + + + ); +} From 6669eeb45651a82f9da3cbf81c30818dda69ccec Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Wed, 27 Aug 2025 14:55:14 +0200 Subject: [PATCH 06/16] [#7] fixed allday implementation to prevent errors with openpaas --- .../features/Events/EventDisplay.test.tsx | 1 + __test__/features/Events/EventModal.test.tsx | 5 ++ __test__/features/Events/eventUtils.test.ts | 37 +++++++-- src/components/Calendar/Calendar.tsx | 2 +- src/features/Events/EventApi.ts | 2 +- src/features/Events/EventDisplay.tsx | 30 +++++-- src/features/Events/EventDisplayPreview.tsx | 75 +++++++++++++++--- src/features/Events/EventModal.tsx | 79 +++++++++++-------- src/features/Events/EventRepeat.tsx | 28 ++++--- src/features/Events/EventsTypes.ts | 2 +- src/features/Events/eventUtils.ts | 19 +++++ src/utils/apiUtils.ts | 4 +- 12 files changed, 210 insertions(+), 74 deletions(-) diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index 2a368a8..8eb9e90 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -109,6 +109,7 @@ describe("Event Display", () => { const dayOfMonth = day.getDate().toString(); expect(screen.getByText("Test Event")).toBeInTheDocument(); + preview.debug(); expect(screen.getByText(new RegExp(weekday, "i"))).toBeInTheDocument(); expect(screen.getByText(new RegExp(month, "i"))).toBeInTheDocument(); expect( diff --git a/__test__/features/Events/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx index 57a19b6..3ecfba6 100644 --- a/__test__/features/Events/EventModal.test.tsx +++ b/__test__/features/Events/EventModal.test.tsx @@ -116,7 +116,12 @@ describe("EventPopover", () => { expect(screen.getByLabelText("End")).toBeInTheDocument(); expect(screen.getByLabelText("Description")).toBeInTheDocument(); expect(screen.getByLabelText("Location")).toBeInTheDocument(); + expect(screen.getByText("Show More")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Show More")); expect(screen.getByLabelText("Repetition")).toBeInTheDocument(); + expect(screen.getByLabelText("Alarm")).toBeInTheDocument(); + expect(screen.getByLabelText("Visibility")).toBeInTheDocument(); + expect(screen.getByLabelText("is Busy")).toBeInTheDocument(); expect(screen.getByLabelText("Time Zone")).toBeInTheDocument(); // Calendar options const select = screen.getByLabelText("Calendar"); diff --git a/__test__/features/Events/eventUtils.test.ts b/__test__/features/Events/eventUtils.test.ts index 0dd1b55..2431996 100644 --- a/__test__/features/Events/eventUtils.test.ts +++ b/__test__/features/Events/eventUtils.test.ts @@ -31,7 +31,12 @@ describe("parseCalendarEvent", () => { ["DTSTAMP", {}, "date-time", "2025-07-18T08:00:00Z"], ] as unknown as [string, Record, string, any]; - const result = parseCalendarEvent(rawData, baseColor, calendarId); + const result = parseCalendarEvent( + rawData, + baseColor, + calendarId, + "/calendars/test.ics" + ); expect(result.uid).toBe("event-1"); expect(result.title).toBe("Team Meeting"); @@ -71,7 +76,12 @@ describe("parseCalendarEvent", () => { ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], ]; - const result = parseCalendarEvent(rawData, baseColor, calendarId); + const result = parseCalendarEvent( + rawData, + baseColor, + calendarId, + "/calendars/test.ics" + ); expect(result.uid).toBe("event-2/2025-07-18T09:00:00Z"); }); @@ -81,7 +91,12 @@ describe("parseCalendarEvent", () => { ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], ]; - const result = parseCalendarEvent(rawDataMissingUid, baseColor, calendarId); + const result = parseCalendarEvent( + rawDataMissingUid, + baseColor, + calendarId, + "/calendars/test.ics" + ); expect(result.error).toMatch(/missing crucial event param/); const rawDataMissingStart: any = [["UID", {}, "text", "event-3"]]; @@ -89,7 +104,8 @@ describe("parseCalendarEvent", () => { const result2 = parseCalendarEvent( rawDataMissingStart, baseColor, - calendarId + calendarId, + "/calendars/test.ics" ); expect(result2.error).toMatch(/missing crucial event param/); }); @@ -102,7 +118,12 @@ describe("parseCalendarEvent", () => { ["ORGANIZER", {}, "cal-address", "jane@example.com"], ] as unknown as [string, Record, string, any]; - const result = parseCalendarEvent(rawData, baseColor, calendarId); + const result = parseCalendarEvent( + rawData, + baseColor, + calendarId, + "/calendars/test.ics" + ); expect(result.attendee).toEqual([ { @@ -135,6 +156,8 @@ describe("calendarEventToJCal", () => { it("should convert a CalendarEvent to JCal format", () => { const mockEvent = { uid: "event-123", + URL: "/calendars/test.ics", + calId: "test/test", title: "Team Meeting", start: new Date("2025-07-23T10:00:00"), end: new Date("2025-07-23T11:00:00"), @@ -246,6 +269,8 @@ describe("calendarEventToJCal", () => { it("should convert a CalendarEvent to JCal format, with all day activated", () => { const mockEvent = { uid: "event-123", + URL: "/calendars/test.ics", + calId: "test/test", title: "Team Meeting", start: new Date("2025-07-23"), end: new Date("2025-07-23"), @@ -285,7 +310,7 @@ describe("calendarEventToJCal", () => { ["summary", {}, "text", "Team Meeting"], ["transp", {}, "text", "OPAQUE"], ["dtstart", { tzid: "Europe/Paris" }, "date", "2025-07-23"], - ["dtend", { tzid: "Europe/Paris" }, "date", "2025-07-23"], + ["dtend", { tzid: "Europe/Paris" }, "date", "2025-07-24"], ["class", {}, "text", "PUBLIC"], ["location", {}, "text", "Room 101"], ["description", {}, "text", "Discuss project roadmap."], diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index 3c17868..e1fec6e 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -372,7 +372,7 @@ export default function CalendarApp() { start: computedNewStart, end: computedNewEnd, } as CalendarEvent; - + console.log(event , newEvent); dispatch( putEventAsync({ cal: calendars[newEvent.calId], newEvent }) ); diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index 04616de..a288b77 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -17,7 +17,7 @@ export async function putEvent(event: CalendarEvent) { } export async function deleteEvent(eventURL: string) { - const response = await api(eventURL, { + const response = await api(`dav${eventURL}`, { method: "DELETE", }).json(); return response; diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 036ecdd..12940bb 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -321,7 +321,20 @@ export default function EventDisplayModal({ setAllDay(!allday)} + onChange={() => { + const endDate = new Date(end); + const startDate = new Date(start); + setAllDay(!allday); + console.log( + endDate.getDate() === startDate.getDate(), + endDate.getDate(), + startDate.getDate() + ); + if (endDate.getDate() === startDate.getDate()) { + endDate.setDate(startDate.getDate() + 1); + setEnd(formatLocalDateTime(endDate)); + } + }} /> } label="All day" @@ -407,8 +420,9 @@ export default function EventDisplayModal({ {showMore && ( <> @@ -436,10 +450,10 @@ export default function EventDisplayModal({ - Class + Visibility - setRepetition(e.target.value) - } - > - No Repetition - Repeat daily - Repeat weekly - Repeat monthly - Repeat yearly - - - + Time Zone setEventClass(e.target.value) @@ -339,10 +345,21 @@ function EventPopover({ Private - + + + is Busy + + )} diff --git a/src/features/Events/EventRepeat.tsx b/src/features/Events/EventRepeat.tsx index 5c2db9f..516e219 100644 --- a/src/features/Events/EventRepeat.tsx +++ b/src/features/Events/EventRepeat.tsx @@ -7,23 +7,29 @@ import { } from "@mui/material"; export default function RepeatEvent({ - eventClass, - setEventClass, + repetition, + setRepetition, + isOwn = true, }: { - eventClass: string; - setEventClass: React.Dispatch>; + repetition: string; + setRepetition: Function; + isOwn?: boolean; }) { return ( - is Busy + Repetition ); diff --git a/src/features/Events/EventsTypes.ts b/src/features/Events/EventsTypes.ts index 22c68b9..2d005be 100644 --- a/src/features/Events/EventsTypes.ts +++ b/src/features/Events/EventsTypes.ts @@ -8,7 +8,7 @@ export interface CalendarEvent { start: Date; // ISO date end?: Date; class?: string; - x_openpass_videoconference?: unknown; + x_openpass_videoconference?: string; title?: string; description?: string; location?: string; diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index 0a55eeb..17ffa30 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -13,6 +13,7 @@ export function parseCalendarEvent( ): CalendarEvent { const event: Partial = { color, attendee: [] }; let recurrenceId; + const dateRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; for (const [key, params, type, value] of data) { switch (key.toLowerCase()) { @@ -24,9 +25,19 @@ export function parseCalendarEvent( break; case "dtstart": event.start = value; + if (dateRegex.test(value)) { + event.allday = true; + } else { + event.allday = false; + } break; case "dtend": event.end = value; + if (dateRegex.test(value)) { + event.allday = true; + } else { + event.allday = false; + } break; case "class": event.class = value; @@ -115,6 +126,14 @@ export function calendarEventToJCal(event: CalendarEvent): any[] { ]; if (event.end) { + console.log( + event.end, + event.start, + event.end.getTime() === event.start.getTime() + ); + if (event.allday && event.end.getTime() === event.start.getTime()) { + event.end.setDate(event.start.getDate() + 1); + } vevent[1].push([ "dtend", { tzid }, diff --git a/src/utils/apiUtils.ts b/src/utils/apiUtils.ts index 908d8c6..2050e24 100644 --- a/src/utils/apiUtils.ts +++ b/src/utils/apiUtils.ts @@ -44,11 +44,11 @@ export function getLocation() { return window.location.href; } -export function isValidUrl(string: string) { +export function isValidUrl(string?: string) { let url; try { - url = new URL(string); + url = new URL(string ?? ""); } catch (_) { return false; } From a2b0ab32b710d5245d55c532976792a011238d4c Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Thu, 28 Aug 2025 13:08:10 +0200 Subject: [PATCH 07/16] [#7] added MOVE support for changing calendar for event + missing tests for this PR --- __test__/features/Events/EventApi.test.tsx | 108 ++++ .../features/Events/EventDisplay.test.tsx | 586 +++++++++++++++++- src/features/Calendars/CalendarSlice.ts | 57 +- src/features/Events/EventApi.ts | 12 +- src/features/Events/EventDisplay.tsx | 53 +- src/features/Events/EventDisplayPreview.tsx | 7 - src/features/Events/EventModal.tsx | 7 - src/features/Events/eventUtils.ts | 5 - 8 files changed, 785 insertions(+), 50 deletions(-) create mode 100644 __test__/features/Events/EventApi.test.tsx diff --git a/__test__/features/Events/EventApi.test.tsx b/__test__/features/Events/EventApi.test.tsx new file mode 100644 index 0000000..69b6a70 --- /dev/null +++ b/__test__/features/Events/EventApi.test.tsx @@ -0,0 +1,108 @@ +import { + putEvent, + moveEvent, + deleteEvent, +} from "../../../src/features/Events/EventApi"; +import { calendarEventToJCal } from "../../../src/features/Events/eventUtils"; +import { clientConfig } from "../../../src/features/User/oidcAuth"; +import { api } from "../../../src/utils/apiUtils"; +clientConfig.url = "https://example.com"; + +jest.mock("../../../src/utils/apiUtils"); + +const day = new Date(); + +const mockEvent = { + uid: "event1", + title: "Test Event", + timezone: "UTC", + calId: "667037022b752d0026472254/cal1", + URL: "/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics", + start: day, + end: day, + status: "PUBLIC", + organizer: { cn: "test", cal_address: "test@test.com" }, + attendee: [ + { + cn: "test", + cal_address: "test@test.com", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + }, + { + cn: "John", + cal_address: "john@test.com", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + }, + ], +}; + +describe("eventApi", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("putEvent sends PUT request with JCal body", async () => { + const mockResponse = { status: 201, url: "/dav/cals/test.ics" }; + (api as unknown as jest.Mock).mockReturnValue(mockResponse); + const result = await putEvent(mockEvent); + const expectedResult = calendarEventToJCal(mockEvent); + expect(api).toHaveBeenCalledWith( + "dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics", + expect.objectContaining({ + method: "PUT", + headers: { "content-type": "text/calendar; charset=utf-8" }, + body: JSON.stringify(expectedResult), + }) + ); + expect(result).toBe(mockResponse); + }); + + test("putEvent logs when status is 201", async () => { + const mockResponse = { status: 201, url: "/dav/cals/test.ics" }; + (api as unknown as jest.Mock).mockReturnValue(mockResponse); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + await putEvent(mockEvent); + expect(logSpy).toHaveBeenCalledWith("PUT (201) :", "/dav/cals/test.ics"); + + logSpy.mockRestore(); + }); + + test("moveEvent sends MOVE request with destination header", async () => { + const mockResponse = { status: 204 }; + (api as unknown as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse), + }); + const result = await moveEvent(mockEvent); + + expect(api).toHaveBeenCalledWith( + "dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics", + expect.objectContaining({ + method: "MOVE", + headers: { + destination: + "/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics", + }, + }) + ); + }); + + test("deleteEvent sends DELETE request and returns json response", async () => { + const mockResponse = { ok: true }; + (api as unknown as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const result = await deleteEvent("/calendars/test.ics"); + + expect(api).toHaveBeenCalledWith("dav/calendars/test.ics", { + method: "DELETE", + }); + }); +}); diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index 8eb9e90..42190f4 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -1,13 +1,14 @@ import { screen, fireEvent, waitFor } from "@testing-library/react"; import * as eventThunks from "../../../src/features/Calendars/CalendarSlice"; import { renderWithProviders } from "../../utils/Renderwithproviders"; -import EventPopover from "../../../src/features/Events/EventModal"; -import { DateSelectArg } from "@fullcalendar/core"; -import preview from "jest-preview"; -import { formatDateToYYYYMMDDTHHMMSS } from "../../../src/utils/dateUtils"; +import EventDisplayModal, { + InfoRow, + stringAvatar, + stringToColor, +} from "../../../src/features/Events/EventDisplay"; import EventPreviewModal from "../../../src/features/Events/EventDisplayPreview"; -describe("Event Display", () => { +describe("Event Preview Display", () => { const mockOnClose = jest.fn(); const day = new Date(); const RealDateToLocaleString = Date.prototype.toLocaleString; @@ -109,7 +110,6 @@ describe("Event Display", () => { const dayOfMonth = day.getDate().toString(); expect(screen.getByText("Test Event")).toBeInTheDocument(); - preview.debug(); expect(screen.getByText(new RegExp(weekday, "i"))).toBeInTheDocument(); expect(screen.getByText(new RegExp(month, "i"))).toBeInTheDocument(); expect( @@ -475,3 +475,577 @@ describe("Event Display", () => { expect(updatedEvent.attendee[0].partstat).toBe("DECLINED"); }); }); + +describe("Event Full Display", () => { + const mockOnClose = jest.fn(); + const day = new Date(); + const RealDateToLocaleString = Date.prototype.toLocaleString; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const preloadedState = { + user: { + userData: { + sub: "test", + email: "test@test.com", + sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", + openpaasId: "667037022b752d0026472254", + }, + organiserData: { + cn: "test", + cal_address: "mailto:test@test.com", + }, + }, + calendars: { + list: { + "667037022b752d0026472254/cal1": { + id: "667037022b752d0026472254/cal1", + name: "First Calendar", + color: "#FF0000", + events: { + event1: { + id: "event1", + title: "Test Event", + calId: "667037022b752d0026472254/cal1", + start: day.toISOString(), + end: day.toISOString(), + organizer: { cn: "test", cal_address: "test@test.com" }, + attendee: [ + { + cn: "test", + cal_address: "test@test.com", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + }, + { + cn: "John", + cal_address: "john@test.com", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + }, + ], + }, + }, + }, + "otherCal/cal": { + id: "otherCal/cal", + name: "Calendar 1", + color: "#FF0000", + events: { + event1: { + id: "event1", + calId: "otherCal/cal", + title: "Test Event Other cal", + start: day.toISOString(), + end: day.toISOString(), + organizer: { cn: "john", cal_address: "john@test.com" }, + }, + }, + }, + }, + pending: false, + }, + }; + + it("renders correctly event data", () => { + jest + .spyOn(Date.prototype, "toLocaleString") + .mockImplementation(function ( + this: Date, + locales?: Intl.LocalesArgument, + options?: Intl.DateTimeFormatOptions | undefined + ): string { + return RealDateToLocaleString.call(this, "en-UK", options); + }); + renderWithProviders( + , + preloadedState + ); + day.setHours(day.getHours() + 2); + const date = day.toISOString().slice(0, 16); + + expect(screen.getByDisplayValue("Test Event")).toBeInTheDocument(); + expect( + screen.getAllByDisplayValue(new RegExp(date, "i"))[0] + ).toBeInTheDocument(); + expect( + screen.getAllByDisplayValue(new RegExp(date, "i")).length + ).toBeLessThanOrEqual(2); + + expect(screen.getByText("First Calendar")).toBeInTheDocument(); + }); + it("calls onClose when Cancel clicked", () => { + renderWithProviders( + , + preloadedState + ); + fireEvent.click(screen.getAllByTestId("CloseIcon")[0]); + + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + it("Shows delete button only when calendar is own", () => { + // Renders the other cal event + renderWithProviders( + , + preloadedState + ); + expect(screen.queryByTestId("DeleteIcon")).not.toBeInTheDocument(); + // Renders the personnal cal event + renderWithProviders( + , + preloadedState + ); + expect(screen.queryByTestId("DeleteIcon")).toBeInTheDocument(); + }); + it("calls delete when Delete clicked", async () => { + renderWithProviders( + , + preloadedState + ); + const spy = jest + .spyOn(eventThunks, "deleteEventAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + fireEvent.click(screen.getByTestId("DeleteIcon")); + + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + }); + + const receivedPayload = spy.mock.calls[0][0]; + + expect(receivedPayload).toEqual({ + calId: "667037022b752d0026472254/cal1", + eventId: "event1", + }); + + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + it("renders RSVP buttons when user is an attendee", () => { + const rsvpStateIsOrga = { + ...preloadedState, + calendars: { + ...preloadedState.calendars, + list: { + ...preloadedState.calendars.list, + "667037022b752d0026472254/cal1": { + ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + events: { + event1: { + ...preloadedState.calendars.list[ + "667037022b752d0026472254/cal1" + ].events.event1, + attendee: [ + { + cal_address: "test@test.com", + cn: "Test User", + partstat: "NEEDS-ACTION", + }, + { + cal_address: "organizer@test.com", + cn: "Test Organizer", + partstat: "NEEDS-ACTION", + }, + ], + organizer: { + cal_address: "organizer@test.com", + }, + }, + }, + }, + }, + }, + }; + + renderWithProviders( + , + rsvpStateIsOrga + ); + + expect(screen.getByRole("button", { name: "Accept" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Maybe" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Decline" })).toBeInTheDocument(); + }); + it("doesnt renders RSVP buttons when user isnt an attendee", () => { + const rsvpStateIsOrga = { + ...preloadedState, + calendars: { + ...preloadedState.calendars, + list: { + ...preloadedState.calendars.list, + "667037022b752d0026472254/cal1": { + ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + events: { + event1: { + ...preloadedState.calendars.list[ + "667037022b752d0026472254/cal1" + ].events.event1, + attendee: [ + { + cal_address: "organizer@test.com", + cn: "Test Organizer", + partstat: "NEEDS-ACTION", + }, + ], + organizer: { + cal_address: "organizer@test.com", + }, + }, + }, + }, + }, + }, + }; + + renderWithProviders( + , + rsvpStateIsOrga + ); + + expect( + screen.queryByRole("button", { name: "Accept" }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Maybe" }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Decline" }) + ).not.toBeInTheDocument(); + }); + + it("handles RSVP Accept click", async () => { + const spy = jest + .spyOn(eventThunks, "putEventAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + const rsvpState = { + ...preloadedState, + calendars: { + ...preloadedState.calendars, + list: { + ...preloadedState.calendars.list, + "667037022b752d0026472254/cal1": { + ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + events: { + event1: { + ...preloadedState.calendars.list[ + "667037022b752d0026472254/cal1" + ].events.event1, + attendee: [ + { + cal_address: "test@test.com", + cn: "Test User", + partstat: "NEEDS-ACTION", + }, + ], + organizer: { + cal_address: "organizer@test.com", + }, + }, + }, + }, + }, + }, + }; + + renderWithProviders( + , + rsvpState + ); + + fireEvent.click(screen.getByRole("button", { name: "Accept" })); + + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + + const updatedEvent = spy.mock.calls[0][0].newEvent; + expect(updatedEvent.attendee[0].partstat).toBe("ACCEPTED"); + }); + + it("handles RSVP Maybe click", async () => { + const spy = jest + .spyOn(eventThunks, "putEventAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + const rsvpState = { + ...preloadedState, + calendars: { + ...preloadedState.calendars, + list: { + ...preloadedState.calendars.list, + "667037022b752d0026472254/cal1": { + ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + events: { + event1: { + ...preloadedState.calendars.list[ + "667037022b752d0026472254/cal1" + ].events.event1, + attendee: [ + { + cal_address: "test@test.com", + cn: "Test User", + partstat: "NEEDS-ACTION", + }, + ], + organizer: { + cal_address: "organizer@test.com", + }, + }, + }, + }, + }, + }, + }; + + renderWithProviders( + , + rsvpState + ); + + fireEvent.click(screen.getByRole("button", { name: "Maybe" })); + + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + + const updatedEvent = spy.mock.calls[0][0].newEvent; + expect(updatedEvent.attendee[0].partstat).toBe("TENTATIVE"); + }); + + it("handles RSVP Decline click", async () => { + const spy = jest + .spyOn(eventThunks, "putEventAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + const rsvpState = { + ...preloadedState, + calendars: { + ...preloadedState.calendars, + list: { + ...preloadedState.calendars.list, + "667037022b752d0026472254/cal1": { + ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + events: { + event1: { + ...preloadedState.calendars.list[ + "667037022b752d0026472254/cal1" + ].events.event1, + attendee: [ + { + cal_address: "test@test.com", + cn: "Test User", + partstat: "NEEDS-ACTION", + }, + ], + organizer: { + cal_address: "organizer@test.com", + }, + }, + }, + }, + }, + }, + }; + + renderWithProviders( + , + rsvpState + ); + + fireEvent.click(screen.getByRole("button", { name: "Decline" })); + + await waitFor(() => { + expect(spy).toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); + }); + + const updatedEvent = spy.mock.calls[0][0].newEvent; + expect(updatedEvent.attendee[0].partstat).toBe("DECLINED"); + }); + test("toggle Show More reveals extra fields", () => { + renderWithProviders( + , + preloadedState + ); + fireEvent.click(screen.getByText("Show More")); + expect(screen.getByLabelText("Alarm")).toBeInTheDocument(); + expect(screen.getByLabelText("Repetition")).toBeInTheDocument(); + expect(screen.getByLabelText("Visibility")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Show Less")); + }); + + test("can edit title when user is organizer", () => { + renderWithProviders( + , + preloadedState + ); + const titleField = screen.getByLabelText("Title"); + fireEvent.change(titleField, { target: { value: "New Title" } }); + expect(screen.getByDisplayValue("New Title")).toBeInTheDocument(); + }); + test("calendar select is disabled when not organizer", () => { + const rsvpState = { + ...preloadedState, + calendars: { + ...preloadedState.calendars, + list: { + ...preloadedState.calendars.list, + "667037022b752d0026472254/cal1": { + ...preloadedState.calendars.list["667037022b752d0026472254/cal1"], + events: { + event1: { + ...preloadedState.calendars.list[ + "667037022b752d0026472254/cal1" + ].events.event1, + attendee: [ + { + cal_address: "test@test.com", + cn: "Test User", + partstat: "NEEDS-ACTION", + }, + ], + organizer: { + cal_address: "organizer@test.com", + }, + }, + }, + }, + }, + }, + }; + + renderWithProviders( + , + rsvpState + ); + expect(screen.getByLabelText("Calendar")).toHaveClass("Mui-disabled"); + }); + test("toggle all-day updates end date correctly", () => { + renderWithProviders( + , + preloadedState + ); + const allDayCheckbox = screen.getByLabelText("All day"); + fireEvent.click(allDayCheckbox); + expect(allDayCheckbox).toBeChecked(); + const date = day.toISOString().split("T")[0]; + + expect( + screen.getAllByDisplayValue(new RegExp(date, "i"))[0] + ).toBeInTheDocument(); + }); +}); + +describe("Helper functions", () => { + test("stringToColor generates consistent color", () => { + expect(stringToColor("Alice")).toMatch(/^#[0-9a-f]{6}$/); + expect(stringToColor("Alice")).toBe(stringToColor("Alice")); + }); + + test("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", () => { + renderWithProviders( + ico} + text="Meeting" + data="https://example.com" + /> + ); + expect(screen.getByText("Meeting").closest("a")).toHaveAttribute( + "href", + "https://example.com" + ); + }); +}); diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index b7435e2..cdbbd4e 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -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, putEvent } from "../Events/EventApi"; +import { deleteEvent, moveEvent, putEvent } from "../Events/EventApi"; import { formatDateToYYYYMMDDTHHMMSS } from "../../utils/dateUtils"; export const getCalendarsListAsync = createAsyncThunk< @@ -92,6 +92,32 @@ export const putEventAsync = createAsyncThunk< events, }; }); +export const moveEventAsync = createAsyncThunk< + { calId: string; events: CalendarEvent[] }, // Return type + { cal: Calendars; newEvent: CalendarEvent } // Arg type +>("calendars/moveEvent", async ({ cal, newEvent }) => { + const response = await moveEvent(newEvent); + const calEvents = (await getCalendar(cal.id, { + start: formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.start)), + end: formatDateToYYYYMMDDTHHMMSS( + new Date(new Date(newEvent.start).getTime() + 86400000) + ), + })) as Record; + const events: CalendarEvent[] = calEvents._embedded["dav:item"].flatMap( + (eventdata: any) => { + const vevents = eventdata.data[2] as any[][]; + const eventURL = eventdata._links.self.href; + return vevents.map((vevent: any[]) => { + return parseCalendarEvent(vevent[1], cal.color ?? "", cal.id, eventURL); + }); + } + ); + + return { + calId: cal.id, + events, + }; +}); export const deleteEventAsync = createAsyncThunk< { calId: string; eventId: string }, // Return type @@ -207,6 +233,32 @@ const CalendarSlice = createSlice({ }); } ) + .addCase( + moveEventAsync.fulfilled, + ( + state, + action: PayloadAction<{ calId: string; events: CalendarEvent[] }> + ) => { + state.pending = false; + if (!state.list[action.payload.calId]) { + state.list[action.payload.calId] = { + id: action.payload.calId, + events: {}, + } as Calendars; + } + action.payload.events.forEach((event) => { + state.list[action.payload.calId].events[event.uid] = event; + }); + Object.keys(state.list[action.payload.calId].events).forEach((id) => { + state.list[action.payload.calId].events[id].color = + state.list[action.payload.calId].color; + state.list[action.payload.calId].events[id].calId = + action.payload.calId; + state.list[action.payload.calId].events[id].timezone = + Intl.DateTimeFormat().resolvedOptions().timeZone; + }); + } + ) .addCase(deleteEventAsync.fulfilled, (state, action) => { state.pending = false; delete state.list[action.payload.calId].events[action.payload.eventId]; @@ -220,6 +272,9 @@ const CalendarSlice = createSlice({ .addCase(putEventAsync.pending, (state) => { state.pending = true; }) + .addCase(moveEventAsync.pending, (state) => { + state.pending = true; + }) .addCase(deleteEventAsync.pending, (state) => { state.pending = true; }); diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index a288b77..018cf1e 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -16,9 +16,19 @@ export async function putEvent(event: CalendarEvent) { return response; } +export async function moveEvent(event: CalendarEvent) { + const response = await api(`dav${event.URL}`, { + method: "MOVE", + headers: { + destination: event.URL, + }, + }); + return response; +} + export async function deleteEvent(eventURL: string) { const response = await api(`dav${eventURL}`, { method: "DELETE", - }).json(); + }); return response; } diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 12940bb..ecf3bd6 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -1,5 +1,9 @@ import { useEffect, useState } from "react"; -import { deleteEventAsync, putEventAsync } from "../Calendars/CalendarSlice"; +import { + deleteEventAsync, + moveEventAsync, + putEventAsync, +} from "../Calendars/CalendarSlice"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import AttendeeSelector from "../../components/Attendees/AttendeeSearch"; import { @@ -86,7 +90,7 @@ export default function EventDisplayModal({ 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 ? userPersonnalCalendars.findIndex((cal) => cal.id === calId) @@ -169,6 +173,12 @@ export default function EventDisplayModal({ newEvent, }) ); + + if (newCalId !== calId) { + dispatch( + moveEventAsync({ cal: userPersonnalCalendars[calendarid], newEvent }) + ); + } onClose({}, "backdropClick"); }; @@ -279,9 +289,11 @@ export default function EventDisplayModal({ labelId="calendar-select-label" value={calendarid.toString()} label="Calendar" - onChange={(e: SelectChangeEvent) => - setCalendarid(Number(e.target.value)) - } + onChange={(e: SelectChangeEvent) => { + const newId = Number(e.target.value); + setCalendarid(newId); + setNewCalId(userPersonnalCalendars[newId].id); + }} > {calList} @@ -325,11 +337,6 @@ export default function EventDisplayModal({ const endDate = new Date(end); const startDate = new Date(start); setAllDay(!allday); - console.log( - endDate.getDate() === startDate.getDate(), - endDate.getDate(), - startDate.getDate() - ); if (endDate.getDate() === startDate.getDate()) { endDate.setDate(startDate.getDate() + 1); setEnd(formatLocalDateTime(endDate)); @@ -390,7 +397,6 @@ export default function EventDisplayModal({ onClick={() => { const newAttendeesList = [...attendees]; setAttendees(newAttendeesList.splice(idx, 1)); - console.log(attendees, newAttendeesList); }} > @@ -496,18 +502,19 @@ export default function EventDisplayModal({ - { - onClose({}, "backdropClick"); - dispatch( - deleteEventAsync({ calId, eventId, eventURL: event.URL }) - ); - }} - > - - - + {isOwn && ( + { + onClose({}, "backdropClick"); + dispatch( + deleteEventAsync({ calId, eventId, eventURL: event.URL }) + ); + }} + > + + + )} diff --git a/src/features/Events/EventDisplayPreview.tsx b/src/features/Events/EventDisplayPreview.tsx index 0d9f8f3..78581e1 100644 --- a/src/features/Events/EventDisplayPreview.tsx +++ b/src/features/Events/EventDisplayPreview.tsx @@ -324,13 +324,6 @@ function formatEnd(start: Date, end: Date, allday?: boolean) { startDate.getDate() === endDate.getDate(); if (allday) { - console.log( - endDate.toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }) - ); return sameDay ? null : endDate.toLocaleDateString(undefined, { diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index a3d838a..9b3ff5e 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -233,14 +233,8 @@ function EventPopover({ const endDate = new Date(end); const startDate = new Date(start); setAllDay(!allday); - console.log( - endDate.getDate() === startDate.getDate(), - endDate.getDate(), - startDate.getDate() - ); if (endDate.getDate() === startDate.getDate()) { endDate.setDate(startDate.getDate() + 1); - console.log("formatedd", formatLocalDateTime(endDate)); setEnd(formatLocalDateTime(endDate)); } @@ -258,7 +252,6 @@ function EventPopover({ ), allDay: allday, }; - console.log(newRange, selectedRange); setSelectedRange(newRange); }} /> diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index 17ffa30..b604603 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -126,11 +126,6 @@ export function calendarEventToJCal(event: CalendarEvent): any[] { ]; if (event.end) { - console.log( - event.end, - event.start, - event.end.getTime() === event.start.getTime() - ); if (event.allday && event.end.getTime() === event.start.getTime()) { event.end.setDate(event.start.getDate() + 1); } From 1c6cdb382b7d77ec8d2461fa84cc04194b8a88fb Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Thu, 28 Aug 2025 15:13:44 +0200 Subject: [PATCH 08/16] [#7] fixed test with incorect date gestion --- __test__/features/Events/EventDisplay.test.tsx | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index 42190f4..093bfb7 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -7,7 +7,6 @@ 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(); @@ -479,7 +478,6 @@ describe("Event Preview Display", () => { describe("Event Full Display", () => { const mockOnClose = jest.fn(); const day = new Date(); - const RealDateToLocaleString = Date.prototype.toLocaleString; beforeEach(() => { jest.clearAllMocks(); @@ -554,15 +552,6 @@ describe("Event Full Display", () => { }; it("renders correctly event data", () => { - jest - .spyOn(Date.prototype, "toLocaleString") - .mockImplementation(function ( - this: Date, - locales?: Intl.LocalesArgument, - options?: Intl.DateTimeFormatOptions | undefined - ): string { - return RealDateToLocaleString.call(this, "en-UK", options); - }); renderWithProviders( { />, preloadedState ); - day.setHours(day.getHours() + 2); - const date = day.toISOString().slice(0, 16); + const tzOffset = day.getTimezoneOffset() * 60000; // offset in ms + const date = new Date(day.getTime() - tzOffset).toISOString().slice(0, 16); expect(screen.getByDisplayValue("Test Event")).toBeInTheDocument(); expect( From 282cbfc80e32d7991f27914c7bd7226121e40cbe Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Thu, 28 Aug 2025 16:27:08 +0200 Subject: [PATCH 09/16] [#7] fixed attendee search in full display --- src/components/Attendees/AttendeeSearch.tsx | 28 +++++++++++++++------ src/features/Events/EventDisplay.tsx | 10 +++++++- src/features/Events/EventModal.tsx | 2 +- src/features/User/userAPI.ts | 14 +++++------ 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/components/Attendees/AttendeeSearch.tsx b/src/components/Attendees/AttendeeSearch.tsx index 6e17588..44636b5 100644 --- a/src/components/Attendees/AttendeeSearch.tsx +++ b/src/components/Attendees/AttendeeSearch.tsx @@ -9,6 +9,7 @@ import { } from "@mui/material"; import { useEffect, useState } from "react"; import { searchUsers } from "../../features/User/userAPI"; +import { userAttendee } from "../../features/User/userDataTypes"; interface User { email: string; @@ -17,9 +18,13 @@ interface User { } export default function UserSearch({ + attendees, setAttendees, + disabled, }: { + attendees: userAttendee[]; setAttendees: Function; + disabled?: boolean; }) { const [query, setQuery] = useState(""); const [options, setOptions] = useState([]); @@ -40,6 +45,7 @@ export default function UserSearch({ x} fullWidth @@ -75,14 +81,20 @@ export default function UserSearch({ }} /> )} - renderOption={(props, option) => ( - - - - - - - )} + renderOption={(props, option) => { + if (attendees.find((a) => a.cal_address === option.email)) return; + return ( + + + + + + + ); + }} /> ); } diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index ecf3bd6..0c7199a 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -360,7 +360,15 @@ export default function EventDisplayModal({ rows={2} /> - + {isOwn && ( + { + const newAttendeeList = attendees.concat(value); + setAttendees(newAttendeeList); + }} + /> + )} - + {/* Extended options */} {showMore && ( <> diff --git a/src/features/User/userAPI.ts b/src/features/User/userAPI.ts index 9c042ac..d6bc2a8 100644 --- a/src/features/User/userAPI.ts +++ b/src/features/User/userAPI.ts @@ -16,14 +16,12 @@ export async function searchUsers(query: string) { }) .json(); - return response - .filter((user) => user.objectType === "user") - .map((user) => ({ - email: user.emailAddresses?.[0]?.value || "", - displayName: - user.names?.[0]?.displayName || user.emailAddresses?.[0]?.value, - avatarUrl: user.photos?.[0]?.url || "", - })); + return response.map((user) => ({ + email: user.emailAddresses?.[0]?.value || "", + displayName: + user.names?.[0]?.displayName || user.emailAddresses?.[0]?.value, + avatarUrl: user.photos?.[0]?.url || "", + })); } export async function getUserDetails(id: string) { From 8ce8839383990ba7108fab013a4d17d0f193e24c Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Thu, 28 Aug 2025 16:45:49 +0200 Subject: [PATCH 10/16] [#7] fixed attendee management in the list --- src/features/Events/EventDisplay.tsx | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 0c7199a..5d09c10 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -400,15 +400,18 @@ export default function EventDisplayModal({ ).map((a, idx) => ( {renderAttendeeBadge(a, idx.toString())} - { - const newAttendeesList = [...attendees]; - setAttendees(newAttendeesList.splice(idx, 1)); - }} - > - - + {isOwn && ( + { + const newAttendeesList = [...attendees]; + newAttendeesList.splice(idx, 1); + setAttendees(newAttendeesList); + }} + > + + + )} ))} {attendees.length > attendeeDisplayLimit && ( From 64f3ff0aa4a038dda3b15a22248190702bac5ace Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Fri, 29 Aug 2025 10:21:43 +0200 Subject: [PATCH 11/16] [#7] fixed move destination --- __test__/features/Events/EventApi.test.tsx | 5 ++--- __test__/features/Events/EventDisplay.test.tsx | 3 --- src/features/Calendars/CalendarSlice.ts | 6 +++--- src/features/Events/EventApi.ts | 4 ++-- src/features/Events/EventDisplay.tsx | 7 +++++-- 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/__test__/features/Events/EventApi.test.tsx b/__test__/features/Events/EventApi.test.tsx index 69b6a70..d6662fe 100644 --- a/__test__/features/Events/EventApi.test.tsx +++ b/__test__/features/Events/EventApi.test.tsx @@ -79,15 +79,14 @@ describe("eventApi", () => { (api as unknown as jest.Mock).mockReturnValue({ json: jest.fn().mockResolvedValue(mockResponse), }); - const result = await moveEvent(mockEvent); + const result = await moveEvent(mockEvent, "newurl.ics"); expect(api).toHaveBeenCalledWith( "dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics", expect.objectContaining({ method: "MOVE", headers: { - destination: - "/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics", + destination: "newurl.ics", }, }) ); diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index 093bfb7..869d918 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -796,7 +796,6 @@ describe("Event Full Display", () => { await waitFor(() => { expect(spy).toHaveBeenCalled(); - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); }); const updatedEvent = spy.mock.calls[0][0].newEvent; @@ -854,7 +853,6 @@ describe("Event Full Display", () => { await waitFor(() => { expect(spy).toHaveBeenCalled(); - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); }); const updatedEvent = spy.mock.calls[0][0].newEvent; @@ -912,7 +910,6 @@ describe("Event Full Display", () => { await waitFor(() => { expect(spy).toHaveBeenCalled(); - expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); }); const updatedEvent = spy.mock.calls[0][0].newEvent; diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index cdbbd4e..e941a51 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -94,9 +94,9 @@ export const putEventAsync = createAsyncThunk< }); export const moveEventAsync = createAsyncThunk< { calId: string; events: CalendarEvent[] }, // Return type - { cal: Calendars; newEvent: CalendarEvent } // Arg type ->("calendars/moveEvent", async ({ cal, newEvent }) => { - const response = await moveEvent(newEvent); + { cal: Calendars; newEvent: CalendarEvent; newURL: string } // Arg type +>("calendars/moveEvent", async ({ cal, newEvent, newURL }) => { + const response = await moveEvent(newEvent, newURL); const calEvents = (await getCalendar(cal.id, { start: formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.start)), end: formatDateToYYYYMMDDTHHMMSS( diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index 018cf1e..add8daf 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -16,11 +16,11 @@ export async function putEvent(event: CalendarEvent) { return response; } -export async function moveEvent(event: CalendarEvent) { +export async function moveEvent(event: CalendarEvent, newUrl: string) { const response = await api(`dav${event.URL}`, { method: "MOVE", headers: { - destination: event.URL, + destination: newUrl, }, }); return response; diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 5d09c10..66917dd 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -132,7 +132,6 @@ export default function EventDisplayModal({ }; dispatch(putEventAsync({ cal: calendar, newEvent })); - onClose({}, "backdropClick"); } const handleSave = () => { @@ -176,7 +175,11 @@ export default function EventDisplayModal({ if (newCalId !== calId) { dispatch( - moveEventAsync({ cal: userPersonnalCalendars[calendarid], newEvent }) + moveEventAsync({ + cal: userPersonnalCalendars[calendarid], + newEvent, + newURL: `/calendars/${newCalId}/${event.uid}.ics`, + }) ); } onClose({}, "backdropClick"); From b6f55da506dbd94328609ab684714ba5f93e75a4 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Fri, 29 Aug 2025 14:50:52 +0200 Subject: [PATCH 12/16] [#7] fixed state update when moving event from calednars --- src/features/Events/EventDisplay.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 66917dd..4bd72cd 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -3,6 +3,7 @@ import { deleteEventAsync, moveEventAsync, putEventAsync, + removeEvent, } from "../Calendars/CalendarSlice"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import AttendeeSelector from "../../components/Attendees/AttendeeSearch"; @@ -134,7 +135,7 @@ export default function EventDisplayModal({ dispatch(putEventAsync({ cal: calendar, newEvent })); } - const handleSave = () => { + const handleSave = async () => { const newEventUID = crypto.randomUUID(); const newEvent: CalendarEvent = { @@ -166,7 +167,7 @@ export default function EventDisplayModal({ color: userPersonnalCalendars[calendarid]?.color, }; - dispatch( + await dispatch( putEventAsync({ cal: userPersonnalCalendars[calendarid], newEvent, @@ -181,6 +182,7 @@ export default function EventDisplayModal({ newURL: `/calendars/${newCalId}/${event.uid}.ics`, }) ); + dispatch(removeEvent({ calendarUid: calId, eventUid: event.uid })); } onClose({}, "backdropClick"); }; From ee01e1d027092453c820d41bcb91419ef29a0885 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Tue, 2 Sep 2025 18:14:55 +0200 Subject: [PATCH 13/16] [#77] added complex ui for repetition, fixed broken tests due to new feature --- .../components/EventModifications.test.tsx | 21 +-- .../features/Events/EventDisplay.test.tsx | 46 +++-- __test__/features/Events/EventModal.test.tsx | 2 - __test__/features/Events/eventUtils.test.ts | 4 +- src/components/Event/EventRepeat.tsx | 164 ++++++++++++++++++ src/components/Menubar/Menubar.tsx | 3 +- src/features/Calendars/CalendarSlice.ts | 35 +++- src/features/Events/EventApi.ts | 16 +- src/features/Events/EventDisplay.tsx | 38 ++-- src/features/Events/EventDisplayPreview.tsx | 14 +- src/features/Events/EventModal.tsx | 10 +- src/features/Events/EventRepeat.tsx | 36 ---- src/features/Events/EventsTypes.ts | 10 +- src/features/Events/eventUtils.ts | 35 +++- 14 files changed, 344 insertions(+), 90 deletions(-) create mode 100644 src/components/Event/EventRepeat.tsx delete mode 100644 src/features/Events/EventRepeat.tsx diff --git a/__test__/components/EventModifications.test.tsx b/__test__/components/EventModifications.test.tsx index e713c75..8a20947 100644 --- a/__test__/components/EventModifications.test.tsx +++ b/__test__/components/EventModifications.test.tsx @@ -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(); + } + }); }); }); diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index 869d918..b210ee0 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -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( { />, 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( { 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( { }); 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( ico} diff --git a/__test__/features/Events/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx index 3ecfba6..bb95e68 100644 --- a/__test__/features/Events/EventModal.test.tsx +++ b/__test__/features/Events/EventModal.test.tsx @@ -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 ); diff --git a/__test__/features/Events/eventUtils.test.ts b/__test__/features/Events/eventUtils.test.ts index 2431996..145af5e 100644 --- a/__test__/features/Events/eventUtils.test.ts +++ b/__test__/features/Events/eventUtils.test.ts @@ -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", diff --git a/src/components/Event/EventRepeat.tsx b/src/components/Event/EventRepeat.tsx new file mode 100644 index 0000000..433fc2f --- /dev/null +++ b/src/components/Event/EventRepeat.tsx @@ -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( + 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 ( + + Repetition + + {repetition.freq && ( + + + Interval: + setInterval(Number(e.target.value))} + size="small" + sx={{ width: 80 }} + /> + + + { + repetitionValues[ + repetitionValues.findIndex((el) => el === repetition.freq) + ] + } + + + {repetition.freq === "weekly" && ( + + + On days: + + + {days.map((day) => ( + handleDayChange(day)} + /> + } + label={day} + /> + ))} + + + )} + + + End: + + setEndOption(e.target.value)} + > + } + label="Never" + /> + + } + label={ + + After + setOccurrences(Number(e.target.value))} + sx={{ width: 100 }} + inputProps={{ min: 1 }} + disabled={endOption !== "after"} + /> + occurrences + + } + /> + + } + label={ + + On + setEndDate(e.target.value)} + disabled={endOption !== "on"} + /> + + } + /> + + + + )} + + ); +} diff --git a/src/components/Menubar/Menubar.tsx b/src/components/Menubar/Menubar.tsx index 37b21fb..cc3ee09 100644 --- a/src/components/Menubar/Menubar.tsx +++ b/src/components/Menubar/Menubar.tsx @@ -74,7 +74,7 @@ export function Menubar() { >
{applist.map((prop: AppIconProps) => ( - + ))}
@@ -96,7 +96,6 @@ export function MainTitle() { function AppIcon({ prop }: { prop: AppIconProps }) { return ( ("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; }) diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index add8daf..734c9f3 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -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}`, { diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 4bd72cd..f8f4678 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -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( + 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) => ( @@ -230,7 +242,7 @@ export default function EventDisplayModal({ - + {/* Title */} ( - + {renderAttendeeBadge(a, idx.toString())} {isOwn && ( is Busy console.log(e.target.value) diff --git a/src/features/Events/EventRepeat.tsx b/src/features/Events/EventRepeat.tsx deleted file mode 100644 index 516e219..0000000 --- a/src/features/Events/EventRepeat.tsx +++ /dev/null @@ -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 ( - - Repetition - - - ); -} diff --git a/src/features/Events/EventsTypes.ts b/src/features/Events/EventsTypes.ts index 2d005be..eb0a13f 100644 --- a/src/features/Events/EventsTypes.ts +++ b/src/features/Events/EventsTypes.ts @@ -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; } diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index b604603..088eb0f 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -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 = { 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) => { From a83bf2fd5cf9d658eecf752733ef332ba8816a31 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Wed, 3 Sep 2025 10:46:22 +0200 Subject: [PATCH 14/16] [#7] removed timezones from popup and added toggle when select free busy --- src/features/Events/EventDisplay.tsx | 7 +++---- src/features/Events/EventModal.tsx | 24 +++--------------------- 2 files changed, 6 insertions(+), 25 deletions(-) diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index f8f4678..a586f47 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -92,6 +92,7 @@ export default function EventDisplayModal({ event.repetition ?? ({} as RepetitionObject) ); const [alarm, setAlarm] = useState(""); + const [busy, setBusy] = useState(""); const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC"); const [timezone, setTimezone] = useState(event?.timezone ?? "UTC"); const [newCalId, setNewCalId] = useState(event.calId); @@ -503,12 +504,10 @@ export default function EventDisplayModal({ is Busy - setTimezone(e.target.value) - } - > - {Object.keys(timezones).map((key) => ( - - {key} - - ))} - - Alarm - console.log(e.target.value) - } + onChange={(e) => setBusy(e.target.value)} > Free Busy From b3c05e0aa547f2b741a4cd2f4a55060f62f8d34f Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Wed, 3 Sep 2025 11:10:17 +0200 Subject: [PATCH 15/16] [#7] fixed attendance update when editing event --- .../features/Events/EventDisplay.test.tsx | 1 + __test__/features/Events/EventModal.test.tsx | 1 - src/features/Events/EventDisplay.tsx | 22 ++++++------------- 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index b210ee0..d69b891 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -991,6 +991,7 @@ describe("Event Full Display", () => { ], organizer: { cal_address: "organizer@test.com", + cn: "Edgar Organiser", }, }, }, diff --git a/__test__/features/Events/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx index bb95e68..fdb4f16 100644 --- a/__test__/features/Events/EventModal.test.tsx +++ b/__test__/features/Events/EventModal.test.tsx @@ -122,7 +122,6 @@ describe("EventPopover", () => { expect(screen.getByLabelText("Alarm")).toBeInTheDocument(); expect(screen.getByLabelText("Visibility")).toBeInTheDocument(); expect(screen.getByLabelText("is Busy")).toBeInTheDocument(); - expect(screen.getByLabelText("Time Zone")).toBeInTheDocument(); // Calendar options const select = screen.getByLabelText("Calendar"); fireEvent.mouseDown(select); diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index a586f47..7ad117d 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -111,9 +111,10 @@ export default function EventDisplayModal({ (person) => person.cal_address === user.userData.email ); - const organizer = event?.attendee?.find( - (a) => a.cal_address === event?.organizer?.cal_address - ); + const organizer = + event.attendee?.find( + (a) => a.cal_address === event?.organizer?.cal_address + ) ?? ({} as userAttendee); const isOwn = organizer?.cal_address === user.userData.email; const isOwnCal = userPersonnalCalendars.find((cal) => cal.id === calId); @@ -155,17 +156,7 @@ export default function EventDisplayModal({ class: eventClass, organizer: event.organizer, timezone, - attendee: [ - { - cn: event.organizer?.cn, - cal_address: event.organizer?.cal_address ?? "", - partstat: "ACCEPTED", - rsvp: "FALSE", - role: "CHAIR", - cutype: "INDIVIDUAL", - }, - ...attendees, - ], + attendee: [organizer, ...attendees], transp: "OPAQUE", color: userPersonnalCalendars[calendarid]?.color, }; @@ -411,7 +402,8 @@ export default function EventDisplayModal({ {event.attendee?.length > 0 && ( Attendees: - {organizer && renderAttendeeBadge(organizer, "org", true)} + {organizer.cal_address && + renderAttendeeBadge(organizer, "org", true)} {(showAllAttendees ? attendees : attendees.slice(0, attendeeDisplayLimit) From a58373854e88d2d4cd313e0c03cb4f7a12d6843d Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Wed, 3 Sep 2025 16:09:21 +0200 Subject: [PATCH 16/16] [#7] removed free/busy field --- __test__/features/Events/EventModal.test.tsx | 1 - src/features/Events/EventDisplay.tsx | 13 ------------- src/features/Events/EventModal.tsx | 13 ------------- 3 files changed, 27 deletions(-) diff --git a/__test__/features/Events/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx index fdb4f16..9016a34 100644 --- a/__test__/features/Events/EventModal.test.tsx +++ b/__test__/features/Events/EventModal.test.tsx @@ -121,7 +121,6 @@ describe("EventPopover", () => { expect(screen.getByLabelText("Repetition")).toBeInTheDocument(); expect(screen.getByLabelText("Alarm")).toBeInTheDocument(); expect(screen.getByLabelText("Visibility")).toBeInTheDocument(); - expect(screen.getByLabelText("is Busy")).toBeInTheDocument(); // Calendar options const select = screen.getByLabelText("Calendar"); fireEvent.mouseDown(select); diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 7ad117d..d716b34 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -492,19 +492,6 @@ export default function EventDisplayModal({ Private - - is Busy - - {/* Error */} {event.error && ( Private - - - is Busy - - )}