From e8a0ac41ef06376e98eccfa4fa216de503e7e4c5 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Mon, 25 Aug 2025 17:01:18 +0200 Subject: [PATCH] [#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], + }; +}