[#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 { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
import { EditModeDialog } from "../Event/EditModeDialog";
import { EventErrorHandler } from "../Error/EventErrorHandler";
import { EventErrorSnackbar } from "../Error/ErrorSnackbar";
interface CalendarAppProps {
calendarRef: React.RefObject<CalendarApi | null>;
@@ -72,6 +74,19 @@ export default function CalendarApp({
// Auto-select personal calendars when first loaded
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(() => {
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}
/>
)}
<EventErrorSnackbar messages={eventErrors} onClose={handleErrorClose} />
</div>
</main>
);
@@ -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<CalendarApi | null>;
@@ -15,6 +16,7 @@ export interface ViewHandlersProps {
onViewChange?: (view: string) => void;
calendars: Record<string, Calendars>;
tempcalendars: Record<string, Calendars>;
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) => {
+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;
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;