[#121] added error handler and snackbar

This commit is contained in:
Camille Moussu
2025-10-08 18:14:15 +02:00
committed by Benoit TELLIER
parent f498309f44
commit a00f179dbd
6 changed files with 187 additions and 51 deletions
@@ -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([]);
});
});
+17
View File
@@ -34,6 +34,8 @@ import {
import { useCalendarEventHandlers } from "./hooks/useCalendarEventHandlers"; import { useCalendarEventHandlers } from "./hooks/useCalendarEventHandlers";
import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers"; import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
import { EditModeDialog } from "../Event/EditModeDialog"; import { EditModeDialog } from "../Event/EditModeDialog";
import { EventErrorHandler } from "../Error/EventErrorHandler";
import { EventErrorSnackbar } from "../Error/ErrorSnackbar";
interface CalendarAppProps { interface CalendarAppProps {
calendarRef: React.RefObject<CalendarApi | null>; calendarRef: React.RefObject<CalendarApi | null>;
@@ -72,6 +74,19 @@ export default function CalendarApp({
// Auto-select personal calendars when first loaded // Auto-select personal calendars when first loaded
const initialLoadRef = useRef(true); const initialLoadRef = useRef(true);
const [eventErrors, setEventErrors] = useState<string[]>([]);
const errorHandler = useRef(new EventErrorHandler());
useEffect(() => {
const handler = errorHandler.current;
handler.setErrorCallback(setEventErrors);
return () => handler.setErrorCallback(() => {});
}, []);
const handleErrorClose = () => {
setEventErrors([]);
errorHandler.current.clearAll();
};
useEffect(() => { useEffect(() => {
if (initialLoadRef.current && Object.keys(calendars).length > 0 && userId) { if (initialLoadRef.current && Object.keys(calendars).length > 0 && userId) {
@@ -191,6 +206,7 @@ export default function CalendarApp({
onViewChange, onViewChange,
calendars, calendars,
tempcalendars, tempcalendars,
errorHandler: errorHandler.current,
}); });
const handleMonthUp = () => { const handleMonthUp = () => {
@@ -439,6 +455,7 @@ export default function CalendarApp({
onClose={eventHandlers.handleCloseEventDisplay} onClose={eventHandlers.handleCloseEventDisplay}
/> />
)} )}
<EventErrorSnackbar messages={eventErrors} onClose={handleErrorClose} />
</div> </div>
</main> </main>
); );
@@ -7,6 +7,7 @@ import { Calendars } from "../../../features/Calendars/CalendarTypes";
import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
import AccessTimeIcon from "@mui/icons-material/AccessTime"; import AccessTimeIcon from "@mui/icons-material/AccessTime";
import LockIcon from "@mui/icons-material/Lock"; import LockIcon from "@mui/icons-material/Lock";
import { EventErrorHandler } from "../../Error/EventErrorHandler";
export interface ViewHandlersProps { export interface ViewHandlersProps {
calendarRef: React.RefObject<CalendarApi | null>; calendarRef: React.RefObject<CalendarApi | null>;
@@ -15,6 +16,7 @@ export interface ViewHandlersProps {
onViewChange?: (view: string) => void; onViewChange?: (view: string) => void;
calendars: Record<string, Calendars>; calendars: Record<string, Calendars>;
tempcalendars: Record<string, Calendars>; tempcalendars: Record<string, Calendars>;
errorHandler: EventErrorHandler;
} }
export const createViewHandlers = (props: ViewHandlersProps) => { export const createViewHandlers = (props: ViewHandlersProps) => {
@@ -25,6 +27,7 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
onViewChange, onViewChange,
calendars, calendars,
tempcalendars, tempcalendars,
errorHandler,
} = props; } = props;
const handleDayHeaderDidMount = (arg: any) => { const handleDayHeaderDidMount = (arg: any) => {
@@ -142,64 +145,77 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
attendee: attendees = [], attendee: attendees = [],
class: classification, class: classification,
} = props; } = props;
try {
const calendarsSource = temp ? tempcalendars : calendars;
const calendar = calendarsSource[calId];
if (!calendar) return null;
const calendarsSource = temp ? tempcalendars : calendars; const isPrivate = ["PRIVATE", "CONFIDENTIAL"].includes(classification);
const calendar = calendarsSource[calId]; const ownerEmails = new Set(
if (!calendar) return null; calendar.ownerEmails?.map((e) => e.toLowerCase())
);
const delegated = calendar.delegated;
let Icon = null;
let titleStyle: React.CSSProperties = {};
const isPrivate = ["PRIVATE", "CONFIDENTIAL"].includes(classification); const showSpecialDisplay = attendees.filter((att: userAttendee) =>
const ownerEmails = new Set( ownerEmails.has(att.cal_address.toLowerCase())
calendar.ownerEmails?.map((e) => e.toLowerCase()) );
); // if no special display
const delegated = calendar.delegated; if (attendees.length && !delegated && !showSpecialDisplay.length) {
let Icon = null; return React.createElement(
let titleStyle: React.CSSProperties = {}; "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( return React.createElement(
"div", "div",
{ style: { display: "flex", alignItems: "center" } }, { 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) => { const handleEventDidMount = (arg: any) => {
+38
View File
@@ -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 (
<Snackbar
open={open}
autoHideDuration={6000}
onClose={onClose}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert
severity="error"
onClose={onClose}
sx={{ width: "100%" }}
action={
<Button color="inherit" size="small" onClick={onClose}>
OK
</Button>
}
>
{summary}
</Alert>
</Snackbar>
);
}
+25
View File
@@ -0,0 +1,25 @@
export class EventErrorHandler {
private errors = new Map<string, string>();
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()));
}
}
+6 -1
View File
@@ -123,12 +123,17 @@ export function parseCalendarEvent(
} }
event.URL = eventURL; event.URL = eventURL;
if (!event.uid || !event.start) { if (!event.uid || !event.start || !event.end) {
console.error( console.error(
`missing crucial event param in calendar ${calendarid} `, `missing crucial event param in calendar ${calendarid} `,
data data
); );
event.error = `missing crucial event param in calendar ${calendarid} `; 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; return event as CalendarEvent;