diff --git a/__test__/components/MiniCalendarColor.test.tsx b/__test__/components/MiniCalendarColor.test.tsx index 4925808..941b3fd 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 = () => { @@ -46,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"); }); @@ -63,9 +64,10 @@ 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()}`; + + const tile = screen.getByTestId(dateTestId); + if (date.getTime() !== today.setHours(0, 0, 0, 0)) { expect(tile?.parentElement).toHaveClass("selectedWeek"); } @@ -79,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"); }); @@ -159,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/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index be282fb..1ab9ee2 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( @@ -197,7 +207,12 @@ export default function CalendarApp() { classNames.push("event-dot"); } - return
; + return ( +
+ ); }} /> setAnchorElCal(null)} /> + {openEventDisplay && eventDisplayedId && eventDisplayedCalId && ( + + )} ); diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 1bf82ba..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 }, @@ -136,6 +145,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; }); } ) @@ -158,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; }) @@ -169,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 new file mode 100644 index 0000000..182aac3 --- /dev/null +++ b/src/features/Events/EventDisplay.tsx @@ -0,0 +1,339 @@ +import { useEffect, useState } from "react"; +import { deleteEventAsync } from "../Calendars/CalendarSlice"; +import { useAppDispatch, useAppSelector } from "../../app/hooks"; +import { + Popover, + Button, + Box, + Typography, + ButtonGroup, + Card, + CardContent, + Divider, + IconButton, + Avatar, + Badge, +} 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"; + +function EventDisplayModal({ + eventId, + calId, + anchorEl, + open, + onClose, +}: { + eventId: string; + calId: string; + anchorEl: HTMLElement | 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); + + 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 + ); + + return ( + + + {/* Top-right buttons */} + + + + + {calendar.id.split("/")[0] === user.userData.openpaasId && ( + { + onClose({}, "backdropClick"); + dispatch(deleteEventAsync({ calId, eventId })); + }} + > + + + )} + 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} + + )} + + + + ); +} + +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; diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index 0dbb61d..2674abe 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: userPersonnalCalendars[calendarid].id, 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;