diff --git a/__test__/features/Error/ErrorHandler.test.tsx b/__test__/features/Error/ErrorHandler.test.tsx new file mode 100644 index 0000000..a87fdb3 --- /dev/null +++ b/__test__/features/Error/ErrorHandler.test.tsx @@ -0,0 +1,35 @@ +import { EventErrorHandler } from "../../../src/components/Error/EventErrorHandler"; + +describe("EventErrorHandler", () => { + it("calls the callback when a new error is reported", () => { + const handler = new EventErrorHandler(); + const callback = jest.fn(); + + handler.setErrorCallback(callback); + handler.reportError("123", "Something broke"); + + expect(callback).toHaveBeenCalledWith(["Something broke"]); + }); + + it("does not duplicate errors for same eventId", () => { + const handler = new EventErrorHandler(); + const callback = jest.fn(); + + handler.setErrorCallback(callback); + handler.reportError("A", "Error 1"); + handler.reportError("A", "Error 1 again"); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("clears errors properly", () => { + const handler = new EventErrorHandler(); + const callback = jest.fn(); + + handler.setErrorCallback(callback); + handler.reportError("A", "Error 1"); + handler.clearAll(); + + expect(callback).toHaveBeenLastCalledWith([]); + }); +}); diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index d03cb5f..4b38c79 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -34,6 +34,8 @@ import { import { useCalendarEventHandlers } from "./hooks/useCalendarEventHandlers"; import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers"; import { EditModeDialog } from "../Event/EditModeDialog"; +import { EventErrorHandler } from "../Error/EventErrorHandler"; +import { EventErrorSnackbar } from "../Error/ErrorSnackbar"; interface CalendarAppProps { calendarRef: React.RefObject; @@ -72,6 +74,19 @@ export default function CalendarApp({ // Auto-select personal calendars when first loaded const initialLoadRef = useRef(true); + const [eventErrors, setEventErrors] = useState([]); + const errorHandler = useRef(new EventErrorHandler()); + + useEffect(() => { + const handler = errorHandler.current; + handler.setErrorCallback(setEventErrors); + return () => handler.setErrorCallback(() => {}); + }, []); + + const handleErrorClose = () => { + setEventErrors([]); + errorHandler.current.clearAll(); + }; useEffect(() => { if (initialLoadRef.current && Object.keys(calendars).length > 0 && userId) { @@ -191,6 +206,7 @@ export default function CalendarApp({ onViewChange, calendars, tempcalendars, + errorHandler: errorHandler.current, }); const handleMonthUp = () => { @@ -439,6 +455,7 @@ export default function CalendarApp({ onClose={eventHandlers.handleCloseEventDisplay} /> )} + ); diff --git a/src/components/Calendar/handlers/viewHandlers.ts b/src/components/Calendar/handlers/viewHandlers.ts index b6c95fb..0f22646 100644 --- a/src/components/Calendar/handlers/viewHandlers.ts +++ b/src/components/Calendar/handlers/viewHandlers.ts @@ -7,6 +7,7 @@ import { Calendars } from "../../../features/Calendars/CalendarTypes"; import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; import AccessTimeIcon from "@mui/icons-material/AccessTime"; import LockIcon from "@mui/icons-material/Lock"; +import { EventErrorHandler } from "../../Error/EventErrorHandler"; export interface ViewHandlersProps { calendarRef: React.RefObject; @@ -15,6 +16,7 @@ export interface ViewHandlersProps { onViewChange?: (view: string) => void; calendars: Record; tempcalendars: Record; + errorHandler: EventErrorHandler; } export const createViewHandlers = (props: ViewHandlersProps) => { @@ -25,6 +27,7 @@ export const createViewHandlers = (props: ViewHandlersProps) => { onViewChange, calendars, tempcalendars, + errorHandler, } = props; const handleDayHeaderDidMount = (arg: any) => { @@ -142,64 +145,77 @@ export const createViewHandlers = (props: ViewHandlersProps) => { attendee: attendees = [], class: classification, } = props; + try { + const calendarsSource = temp ? tempcalendars : calendars; + const calendar = calendarsSource[calId]; + if (!calendar) return null; - const calendarsSource = temp ? tempcalendars : calendars; - const calendar = calendarsSource[calId]; - if (!calendar) return null; + const isPrivate = ["PRIVATE", "CONFIDENTIAL"].includes(classification); + const ownerEmails = new Set( + calendar.ownerEmails?.map((e) => e.toLowerCase()) + ); + const delegated = calendar.delegated; + let Icon = null; + let titleStyle: React.CSSProperties = {}; - const isPrivate = ["PRIVATE", "CONFIDENTIAL"].includes(classification); - const ownerEmails = new Set( - calendar.ownerEmails?.map((e) => e.toLowerCase()) - ); - const delegated = calendar.delegated; - let Icon = null; - let titleStyle: React.CSSProperties = {}; + const showSpecialDisplay = attendees.filter((att: userAttendee) => + ownerEmails.has(att.cal_address.toLowerCase()) + ); + // if no special display + if (attendees.length && !delegated && !showSpecialDisplay.length) { + return React.createElement( + "div", + { style: { display: "flex", alignItems: "center" } }, + React.createElement("span", null, event.title) + ); + } + switch (showSpecialDisplay?.[0]?.partstat) { + case "DECLINED": + Icon = null; + titleStyle.textDecoration = "line-through"; + break; + case "TENTATIVE": + Icon = HelpOutlineIcon; + break; + case "NEEDS-ACTION": + Icon = AccessTimeIcon; + break; + case "ACCEPTED": + Icon = null; + break; + default: + break; + } - const showSpecialDisplay = attendees.filter((att: userAttendee) => - ownerEmails.has(att.cal_address.toLowerCase()) - ); - // if no special display - if (attendees.length && !delegated && !showSpecialDisplay.length) { return React.createElement( "div", { style: { display: "flex", alignItems: "center" } }, - React.createElement("span", event.title) + isPrivate && + React.createElement(LockIcon, { + "data-testid": "lock-icon", + fontSize: "small", + style: { marginRight: "4px" }, + }), + Icon && + React.createElement(Icon, { + fontSize: "small", + style: { marginRight: "4px" }, + }), + React.createElement("span", { style: titleStyle }, event.title) + ); + } catch (e) { + const message = + e instanceof Error ? e.message : "Unknown error during rendering"; + errorHandler.reportError( + event._def.extendedProps.uid || event.id, + message + ); + return React.createElement( + "div", + { style: { display: "flex", alignItems: "center" } }, + React.createElement("span", null, event.title) ); } - switch (showSpecialDisplay?.[0]?.partstat) { - case "DECLINED": - Icon = null; - titleStyle.textDecoration = "line-through"; - break; - case "TENTATIVE": - Icon = HelpOutlineIcon; - break; - case "NEEDS-ACTION": - Icon = AccessTimeIcon; - break; - case "ACCEPTED": - Icon = null; - break; - default: - break; - } - - return React.createElement( - "div", - { style: { display: "flex", alignItems: "center" } }, - isPrivate && - React.createElement(LockIcon, { - "data-testid": "lock-icon", - fontSize: "small", - style: { marginRight: "4px" }, - }), - Icon && - React.createElement(Icon, { - fontSize: "small", - style: { marginRight: "4px" }, - }), - React.createElement("span", { style: titleStyle }, event.title) - ); }; const handleEventDidMount = (arg: any) => { diff --git a/src/components/Error/ErrorSnackbar.tsx b/src/components/Error/ErrorSnackbar.tsx new file mode 100644 index 0000000..fe111c1 --- /dev/null +++ b/src/components/Error/ErrorSnackbar.tsx @@ -0,0 +1,38 @@ +import Snackbar from "@mui/material/Snackbar"; +import Alert from "@mui/material/Alert"; +import Button from "@mui/material/Button"; + +interface Props { + messages: string[]; + onClose: () => void; +} + +export function EventErrorSnackbar({ messages, onClose }: Props) { + const open = messages.length > 0; + const summary = + messages.length === 1 + ? messages[0] + : `${messages.length} events with errors`; + + return ( + + + OK + + } + > + {summary} + + + ); +} diff --git a/src/components/Error/EventErrorHandler.ts b/src/components/Error/EventErrorHandler.ts new file mode 100644 index 0000000..f2f2082 --- /dev/null +++ b/src/components/Error/EventErrorHandler.ts @@ -0,0 +1,25 @@ +export class EventErrorHandler { + private errors = new Map(); + private onErrorCallback?: (messages: string[]) => void; + + setErrorCallback(callback: (messages: string[]) => void) { + this.onErrorCallback = callback; + } + + reportError(eventId: string, message: string) { + if (!this.errors.has(eventId)) { + this.errors.set(eventId, message); + console.warn(`[EventErrorHandler] ${eventId}: ${message}`); + this.emit(); + } + } + + clearAll() { + this.errors.clear(); + this.emit(); + } + + private emit() { + this.onErrorCallback?.(Array.from(this.errors.values())); + } +} diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index fae8203..288c8a0 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -123,12 +123,17 @@ export function parseCalendarEvent( } event.URL = eventURL; - if (!event.uid || !event.start) { + if (!event.uid || !event.start || !event.end) { console.error( `missing crucial event param in calendar ${calendarid} `, data ); event.error = `missing crucial event param in calendar ${calendarid} `; + if (!event.end) { + const start = event.start ? new Date(event.start) : new Date(); + const artificialEnd = new Date(start.getTime() + 3600000); + event.end = formatDateToICal(artificialEnd, false); + } } return event as CalendarEvent;