[#121] added error handler and snackbar
This commit is contained in:
committed by
Benoit TELLIER
parent
f498309f44
commit
a00f179dbd
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user