From 5f59831be0be020a5bbdf14475aa81bab5239633 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Fri, 25 Jul 2025 14:57:23 +0200 Subject: [PATCH 1/4] [#3] first basic modal for event display --- src/components/Calendar/Calendar.tsx | 21 +++ src/features/Calendars/CalendarSlice.ts | 2 + src/features/Events/EventDisplay.tsx | 221 ++++++++++++++++++++++++ src/features/Events/EventModal.tsx | 1 + src/features/Events/EventsTypes.ts | 1 + src/features/User/userDataTypes.ts | 3 + 6 files changed, 249 insertions(+) create mode 100644 src/features/Events/EventDisplay.tsx diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index be282fb..93c9a3b 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -20,6 +20,7 @@ import { } from "../../utils/dateUtils"; import { Calendars } from "../../features/Calendars/CalendarTypes"; import { push } from "redux-first-history"; +import EventDisplayModal from "../../features/Events/EventDisplay"; export default function CalendarApp() { const calendarRef = useRef(null); @@ -93,6 +94,11 @@ export default function CalendarApp() { }, [rangeKey, selectedCalendars]); const [anchorEl, setAnchorEl] = useState(null); + const [anchorElEventDisplay, setAnchorElEventDisplay] = + useState(null); + const [openEventDisplay, setOpenEventDisplay] = useState(false); + const [eventDisplayedId, setEventDisplayedId] = useState(""); + const [eventDisplayedCalId, setEventDisplayedCalId] = useState(""); const [anchorElCal, setAnchorElCal] = useState(null); const [selectedRange, setSelectedRange] = useState( null @@ -108,6 +114,10 @@ export default function CalendarApp() { setAnchorEl(null); setSelectedRange(null); }; + const handleCloseEventDisplay = () => { + setAnchorElEventDisplay(null); + setOpenEventDisplay(false); + }; const handleMonthUp = () => { setSelectedMiniDate( @@ -273,6 +283,10 @@ export default function CalendarApp() { window.open(info.event.url); } else { console.log(info.event); + setOpenEventDisplay(true); + setAnchorElEventDisplay(info.el); + setEventDisplayedId(info.event.extendedProps.uid); + setEventDisplayedCalId(info.event.extendedProps.calId); } }} headerToolbar={{ @@ -294,6 +308,13 @@ export default function CalendarApp() { open={Boolean(anchorElCal)} onClose={() => setAnchorElCal(null)} /> + ); diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 1bf82ba..9385665 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -136,6 +136,8 @@ const CalendarSlice = createSlice({ 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; }); } ) diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx new file mode 100644 index 0000000..eb8df5f --- /dev/null +++ b/src/features/Events/EventDisplay.tsx @@ -0,0 +1,221 @@ +import React, { useEffect, useState } from "react"; +import { addEvent } from "../Calendars/CalendarSlice"; +import { CalendarEvent } from "./EventsTypes"; +import { DateSelectArg } from "@fullcalendar/core"; +import { useAppDispatch, useAppSelector } from "../../app/hooks"; +import { + Popover, + TextField, + Button, + Box, + Typography, + Select, + MenuItem, + SelectChangeEvent, + Avatar, + ButtonGroup, + Card, + CardContent, + Divider, + IconButton, +} from "@mui/material"; +import EventModal from "./EventModal"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import CloseIcon from "@mui/icons-material/Close"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +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"; + +function EventDisplayModal({ + eventId, + calId, + anchorEl, + open, + onClose, +}: { + eventId: string; + calId: string; + anchorEl: HTMLElement | null; + open: boolean; + onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void; +}) { + if (calId && eventId) { + 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 attendeeDisplayLimit = 3; + + const visibleAttendees = showAllAttendees + ? event.attendee + : event.attendee.slice(0, attendeeDisplayLimit); + console.log(event); + return ( + + + {/* Top-right buttons */} + + + + + + + + onClose({}, "backdropClick")} + > + + + + + + {/* Title */} + {event.title && ( + + {event.title} + + )} + + {/* Time info */} + + {formatDate(event.start)} + {event.end && + ` – ${new Date(event.end).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + })}`} + + + {event.location && ( + + + {event.location} + + )} + + {/* Description */} + {event.description && ( + + {event.description} + + )} + + {/* Video conference */} + {event.x_openpass_videoconference && ( + + + + Video conference available + + + )} + + {/* Attendees */} + {/* Attendees with toggle */} + {event.attendee?.length > 0 && ( + + Attendees: + {visibleAttendees.map((a, idx) => ( + + {a.cn} + + ))} + + {event.attendee.length > attendeeDisplayLimit && ( + setShowAllAttendees(!showAllAttendees)} + > + {showAllAttendees + ? "Show less" + : `Show more (${ + event.attendee.length - attendeeDisplayLimit + } more)`} + + )} + + )} + + {/* Error */} + {event.error && ( + + + + {event.error} + + + )} + + {/* Colored dot and calendar icon row */} + + + + {calendar.name} + + + + + {/* Attendance options */} + {event.attendee.find( + (person) => person.cal_address === `mailto:${user.userData.email}` + ) && ( + + + Will you attend? + + + + + + + + )} + + + + ); + } else { + return <>; + } +} + +const formatDate = (date: Date) => + new Date(date).toLocaleString(undefined, { + weekday: "long", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + +export default EventDisplayModal; diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index 0dbb61d..c9f828a 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -70,6 +70,7 @@ function EventPopover({ const handleSave = async () => { const newEvent: CalendarEvent = { + calId: calendarid, title, start: new Date(start), allday, diff --git a/src/features/Events/EventsTypes.ts b/src/features/Events/EventsTypes.ts index c838ab5..8680709 100644 --- a/src/features/Events/EventsTypes.ts +++ b/src/features/Events/EventsTypes.ts @@ -1,6 +1,7 @@ import { userAttendee, userOrganiser } from "../User/userDataTypes"; export interface CalendarEvent { + calId: string; uid: string; transp?: string; start: Date; // ISO date diff --git a/src/features/User/userDataTypes.ts b/src/features/User/userDataTypes.ts index 86aa6c2..a7fb841 100644 --- a/src/features/User/userDataTypes.ts +++ b/src/features/User/userDataTypes.ts @@ -1,5 +1,8 @@ export interface userData { email: string; + family_name: string; + given_name: string; + name: string; sid: string; sub: string; openpaasId?: string; From 2ad41dc4f5d4935c836af76d2dc7b8e9517610d1 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Fri, 25 Jul 2025 15:29:40 +0200 Subject: [PATCH 2/4] [#3] added event deletion to display --- src/components/Calendar/Calendar.tsx | 16 ++++++------ src/features/Calendars/CalendarSlice.ts | 20 ++++++++++++++- src/features/Events/EventApi.ts | 4 +-- src/features/Events/EventDisplay.tsx | 33 ++++++++++++++----------- src/features/Events/EventModal.tsx | 2 +- 5 files changed, 50 insertions(+), 25 deletions(-) diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index 93c9a3b..aa7bff2 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -308,13 +308,15 @@ export default function CalendarApp() { open={Boolean(anchorElCal)} onClose={() => setAnchorElCal(null)} /> - + {openEventDisplay && eventDisplayedId && eventDisplayedCalId && ( + + )} ); diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 9385665..5809f06 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -4,8 +4,9 @@ import { CalendarEvent } from "../Events/EventsTypes"; import { getCalendar, getCalendars } from "./CalendarApi"; import getOpenPaasUserId from "../User/userAPI"; import { parseCalendarEvent } from "../Events/eventUtils"; -import { putEvent } from "../Events/EventApi"; +import { deleteEvent, putEvent } from "../Events/EventApi"; import { formatDateToYYYYMMDDTHHMMSS } from "../../utils/dateUtils"; +import { responsiveFontSizes } from "@mui/material"; export const getCalendarsListAsync = createAsyncThunk< Record // Return type @@ -75,6 +76,14 @@ export const putEventAsync = createAsyncThunk< }; }); +export const deleteEventAsync = createAsyncThunk< + { calId: string; eventId: string }, // Return type + { calId: string; eventId: string } // Arg type +>("calendars/delEvent", async ({ calId, eventId }) => { + const response = await deleteEvent(calId, eventId); + return { calId, eventId }; +}); + const CalendarSlice = createSlice({ name: "calendars", initialState: { list: {} as Record, pending: false }, @@ -160,9 +169,15 @@ const CalendarSlice = createSlice({ 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; }); } ) + .addCase(deleteEventAsync.fulfilled, (state, action) => { + state.pending = false; + delete state.list[action.payload.calId].events[action.payload.eventId]; + }) .addCase(getCalendarDetailAsync.pending, (state) => { state.pending = true; }) @@ -171,6 +186,9 @@ const CalendarSlice = createSlice({ }) .addCase(putEventAsync.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 02c35ee..87cd76c 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -5,7 +5,7 @@ import { calendarEventToJCal } from "./eventUtils"; export async function putEvent(cal: Calendars, event: CalendarEvent) { const response = await api( - `dav/calendars/${cal.id}/${event.uid.split(".")[0]}.isc`, + `dav/calendars/${cal.id}/${event.uid.split("/")[0]}.isc`, { method: "PUT", body: JSON.stringify(calendarEventToJCal(event)), @@ -19,7 +19,7 @@ export async function putEvent(cal: Calendars, event: CalendarEvent) { export async function deleteEvent(calId: string, eventId: string) { const response = await api( - `dav/calendars/${calId}/${eventId.split(".")[0]}.isc`, + `dav/calendars/${calId}/${eventId.split("/")[0]}.isc`, { method: "DELETE", } diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index eb8df5f..3c830da 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -1,30 +1,20 @@ -import React, { useEffect, useState } from "react"; -import { addEvent } from "../Calendars/CalendarSlice"; -import { CalendarEvent } from "./EventsTypes"; -import { DateSelectArg } from "@fullcalendar/core"; +import { useEffect, useState } from "react"; +import { deleteEventAsync } from "../Calendars/CalendarSlice"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { Popover, - TextField, Button, Box, Typography, - Select, - MenuItem, - SelectChangeEvent, - Avatar, ButtonGroup, Card, CardContent, Divider, IconButton, } from "@mui/material"; -import EventModal from "./EventModal"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; -import MoreVertIcon from "@mui/icons-material/MoreVert"; import CloseIcon from "@mui/icons-material/Close"; -import VisibilityIcon from "@mui/icons-material/Visibility"; import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; import LocationOnIcon from "@mui/icons-material/LocationOn"; import VideocamIcon from "@mui/icons-material/Videocam"; @@ -45,6 +35,7 @@ function EventDisplayModal({ onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void; }) { if (calId && eventId) { + const dispatch = useAppDispatch(); const calendar = useAppSelector((state) => state.calendars.list[calId]); const event = useAppSelector( (state) => state.calendars.list[calId].events[eventId] @@ -53,6 +44,14 @@ function EventDisplayModal({ const [showAllAttendees, setShowAllAttendees] = useState(false); const attendeeDisplayLimit = 3; + useEffect(() => { + if (!event || !calendar) { + onClose({}, "backdropClick"); + } + }, [event, calendar, onClose]); + + if (!event || !calendar) return null; + const visibleAttendees = showAllAttendees ? event.attendee : event.attendee.slice(0, attendeeDisplayLimit); @@ -73,7 +72,13 @@ function EventDisplayModal({ - + { + onClose({}, "backdropClick"); + dispatch(deleteEventAsync({ calId, eventId })); + }} + > person.cal_address === `mailto:${user.userData.email}` + (person) => person.cal_address === user.userData.email ) && ( diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index c9f828a..2674abe 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -70,7 +70,7 @@ function EventPopover({ const handleSave = async () => { const newEvent: CalendarEvent = { - calId: calendarid, + calId: userPersonnalCalendars[calendarid].id, title, start: new Date(start), allday, From a5c7265884add7cbb595b290a5113e87063ebc96 Mon Sep 17 00:00:00 2001 From: Camille Moussu Date: Mon, 28 Jul 2025 12:00:55 +0200 Subject: [PATCH 3/4] [fix] broken test due to day number dupplication --- __test__/components/MiniCalendarColor.test.tsx | 11 ++++++++--- src/components/Calendar/Calendar.tsx | 7 ++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/__test__/components/MiniCalendarColor.test.tsx b/__test__/components/MiniCalendarColor.test.tsx index 4925808..4a3e3ab 100644 --- a/__test__/components/MiniCalendarColor.test.tsx +++ b/__test__/components/MiniCalendarColor.test.tsx @@ -12,6 +12,7 @@ describe("MiniCalendar", () => { jest.clearAllMocks(); const dispatch = jest.fn() as ThunkDispatch; jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch); + jest.useFakeTimers().clearAllTimers(); }); const renderCalendar = () => { @@ -63,10 +64,14 @@ describe("MiniCalendar", () => { for (let i = 0; i < 7; i++) { const date = new Date(sunday); date.setDate(sunday.getDate() + i); - const tile = (await screen.findAllByText(date.getDate())).find( - (el) => el.tagName.toLowerCase() === "abbr" - ); + const dateTestId = `date-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; + preview.debug(); + + const tile = screen.getByTestId(dateTestId); + if (date.getTime() !== today.setHours(0, 0, 0, 0)) { + preview.debug(); + expect(tile?.parentElement).toHaveClass("selectedWeek"); } } diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index aa7bff2..1ab9ee2 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -207,7 +207,12 @@ export default function CalendarApp() { classNames.push("event-dot"); } - return
; + return ( +
+ ); }} /> Date: Tue, 29 Jul 2025 11:09:21 +0200 Subject: [PATCH 4/4] [#3] added pretty attendee list and tests + fixed broken tests due to dates --- .../components/MiniCalendarColor.test.tsx | 39 +- .../features/Events/EventDisplay.test.tsx | 172 +++++++ __test__/features/Events/EventModal.test.tsx | 1 - src/features/Events/EventDisplay.tsx | 435 +++++++++++------- 4 files changed, 462 insertions(+), 185 deletions(-) create mode 100644 __test__/features/Events/EventDisplay.test.tsx diff --git a/__test__/components/MiniCalendarColor.test.tsx b/__test__/components/MiniCalendarColor.test.tsx index 4a3e3ab..941b3fd 100644 --- a/__test__/components/MiniCalendarColor.test.tsx +++ b/__test__/components/MiniCalendarColor.test.tsx @@ -47,10 +47,10 @@ describe("MiniCalendar", () => { it("renders mini calendar with today in orange", async () => { renderCalendar(); - const today = new Date().getDate().toString(); - const todayTile = screen - .getAllByText(today) - .find((el) => el.tagName.toLowerCase() === "abbr"); + const today = new Date(); + const dateTestId = `date-${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`; + + const todayTile = screen.getByTestId(dateTestId); expect(todayTile?.parentElement).toHaveClass("today"); }); @@ -65,13 +65,10 @@ describe("MiniCalendar", () => { const date = new Date(sunday); date.setDate(sunday.getDate() + i); const dateTestId = `date-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; - preview.debug(); const tile = screen.getByTestId(dateTestId); if (date.getTime() !== today.setHours(0, 0, 0, 0)) { - preview.debug(); - expect(tile?.parentElement).toHaveClass("selectedWeek"); } } @@ -84,10 +81,10 @@ describe("MiniCalendar", () => { const dayViewButton = await screen.findByTitle(/day view/i); fireEvent.click(dayViewButton); - const today = new Date().getDate().toString(); - const todayTile = screen - .getAllByText(today) - .find((el) => el.tagName.toLowerCase() === "abbr"); + const today = new Date(); + const dateTestId = `date-${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`; + + const todayTile = screen.getByTestId(dateTestId); expect(todayTile?.parentElement).toHaveClass("selectedWeek"); }); @@ -164,23 +161,19 @@ describe("Found Bugs", () => { const previousMonthButton = screen.getByText("<"); fireEvent.click(nextMonthButton); fireEvent.click(previousMonthButton); - const shownDay = screen.getByText((content, element) => { - return ( - element?.className.toLowerCase().includes("fc-daygrid-day-number") ?? - false - ); - }); const selectedTile = screen.getByText((content, element) => { return element?.className.includes("selectedWeek") ?? false; }); - const supposedSelectedTile = screen - .getAllByText((content, element) => { - return element?.tagName.toLowerCase() === "abbr"; - }) - .find((el) => el.innerHTML === shownDay.innerHTML); + const ariaLabel = screen.getByRole("columnheader"); + const shownDayDate = new Date( + ariaLabel.getAttribute("data-date") as string + ); + const dateTestId = `date-${shownDayDate.getFullYear()}-${shownDayDate.getMonth()}-${shownDayDate.getDate()}`; + + const supposedSelectedTile = screen.getByTestId(dateTestId); expect(selectedTile.children[0].innerHTML).toBe( - supposedSelectedTile?.innerHTML + supposedSelectedTile.parentElement?.children[0]?.innerHTML ); expect(supposedSelectedTile?.parentElement).toHaveClass("selectedWeek"); }); diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx new file mode 100644 index 0000000..59d15c3 --- /dev/null +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -0,0 +1,172 @@ +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 from "../../../src/features/Events/EventDisplay"; + +describe("Event 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: "Calendar 1", + color: "#FF0000", + events: { + event1: { + id: "event1", + title: "Test Event", + start: day.toISOString(), + end: day.toISOString(), + }, + }, + }, + "otherCal/cal": { + id: "otherCal/cal", + name: "Calendar 1", + color: "#FF0000", + events: { + event1: { + id: "event1", + title: "Test Event Other cal", + start: day.toISOString(), + end: day.toISOString(), + }, + }, + }, + }, + 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 + ); + const weekday = day.toLocaleString("en-UK", { weekday: "long" }); + const month = day.toLocaleString("en-UK", { month: "long" }); + const dayOfMonth = day.getDate().toString(); + + expect(screen.getByText("Test Event")).toBeInTheDocument(); + expect(screen.getByText(new RegExp(weekday, "i"))).toBeInTheDocument(); + expect(screen.getByText(new RegExp(month, "i"))).toBeInTheDocument(); + expect( + screen.getByText(new RegExp(`\\b${dayOfMonth}\\b`)) + ).toBeInTheDocument(); + + expect(screen.getByText(/\d{2}:\d{2} – \d{2}:\d{2}/)).toBeInTheDocument(); + expect(screen.getByText("Calendar 1")).toBeInTheDocument(); + }); + it("calls onClose when Cancel clicked", () => { + renderWithProviders( + , + preloadedState + ); + fireEvent.click(screen.getByTestId("CloseIcon")); + + 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"); + }); +}); diff --git a/__test__/features/Events/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx index 8f06500..0fb59ab 100644 --- a/__test__/features/Events/EventModal.test.tsx +++ b/__test__/features/Events/EventModal.test.tsx @@ -165,7 +165,6 @@ describe("EventPopover", () => { fireEvent.change(screen.getByLabelText("Location"), { target: { value: newEvent.location }, }); - preview.debug(); const spy = jest .spyOn(eventThunks, "putEventAsync") .mockImplementation((payload) => { diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 3c830da..182aac3 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -11,6 +11,8 @@ import { CardContent, Divider, IconButton, + Avatar, + Badge, } from "@mui/material"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; @@ -20,6 +22,9 @@ 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"; function EventDisplayModal({ eventId, @@ -34,44 +39,58 @@ function EventDisplayModal({ open: boolean; onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void; }) { - if (calId && eventId) { - 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 attendeeDisplayLimit = 3; + 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); - useEffect(() => { - if (!event || !calendar) { - onClose({}, "backdropClick"); - } - }, [event, calendar, onClose]); + useEffect(() => { + if (!event || !calendar) { + onClose({}, "backdropClick"); + } + }, [event, calendar, onClose]); - if (!event || !calendar) return null; + if (!event || !calendar) return null; - const visibleAttendees = showAllAttendees - ? event.attendee - : event.attendee.slice(0, attendeeDisplayLimit); - console.log(event); - return ( - - - {/* Top-right buttons */} - - - - + 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 + ); + + return ( + + + {/* Top-right buttons */} + + + + + {calendar.id.split("/")[0] === user.userData.openpaasId && ( { @@ -81,146 +100,240 @@ function EventDisplayModal({ > - onClose({}, "backdropClick")} - > - - + )} + 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} - - {/* Title */} - {event.title && ( - - {event.title} - - )} + - {/* Time info */} - - {formatDate(event.start)} - {event.end && - ` – ${new Date(event.end).toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - })}`} - - - {event.location && ( - - - {event.location} - - )} - - {/* Description */} - {event.description && ( + {/* RSVP */} + {currentUserAttendee && ( + - {event.description} + Will you attend? - )} - - {/* Video conference */} - {event.x_openpass_videoconference && ( - - - - Video conference available - - - )} - - {/* Attendees */} - {/* Attendees with toggle */} - {event.attendee?.length > 0 && ( - - Attendees: - {visibleAttendees.map((a, idx) => ( - - {a.cn} - - ))} - - {event.attendee.length > attendeeDisplayLimit && ( - setShowAllAttendees(!showAllAttendees)} - > - {showAllAttendees - ? "Show less" - : `Show more (${ - event.attendee.length - attendeeDisplayLimit - } more)`} - - )} - - )} - - {/* Error */} - {event.error && ( - - - - {event.error} - - - )} - - {/* Colored dot and calendar icon row */} - - - - {calendar.name} + + + + + + )} - - - {/* Attendance options */} - {event.attendee.find( - (person) => person.cal_address === user.userData.email - ) && ( - - - Will you attend? - - - - - - - - )} - - - - ); - } else { - return <>; - } + {/* Description */} + {event.description && ( + + {event.description} + + )} + + + + ); } -const formatDate = (date: Date) => - new Date(date).toLocaleString(undefined, { +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", }); +} + +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], + }; +} export default EventDisplayModal;