#204 Error management (#237)

* added error snackbar for api fails
* improved error page to be shown only for userdata errors and to allow retrying
* fixed test breaking only in local because of timezone and reduced warnings for easier reading of tests results
* added error management for failure inside calendars imports
* added error snackbar for api fails
* improved error page to be shown only for userdata errors and to allow retrying
* added error management for failure inside calendars imports

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2025-10-27 11:23:38 +01:00
committed by GitHub
parent 4774e78543
commit 42f5d8e1a9
17 changed files with 960 additions and 371 deletions
+13 -1
View File
@@ -1,4 +1,4 @@
import { Suspense } from "react";
import { Suspense, useEffect } from "react";
import { Route, Routes } from "react-router-dom";
import { HistoryRouter as Router } from "redux-first-history/rr6";
import { CallbackResume } from "./features/User/LoginCallback";
@@ -9,8 +9,18 @@ import HandleLogin from "./features/User/HandleLogin";
import CalendarLayout from "./components/Calendar/CalendarLayout";
import { Error } from "./components/Error/Error";
import { CustomThemeProvider } from "./theme/ThemeProvider";
import { useAppDispatch, useAppSelector } from "./app/hooks";
import { push } from "redux-first-history";
import { ErrorSnackbar } from "./components/Error/ErrorSnackbar";
function App() {
const error = useAppSelector((state) => state.user.error);
const dispatch = useAppDispatch();
useEffect(() => {
if (error) {
dispatch(push("/error"));
}
});
return (
<CustomThemeProvider>
<Suspense fallback={<Loading />}>
@@ -22,6 +32,8 @@ function App() {
<Route path="/error" element={<Error />} />
</Routes>
</Router>
<ErrorSnackbar error={error} type="user" />
<ErrorSnackbar error={error} type="user" />
</Suspense>
</CustomThemeProvider>
);
+1 -1
View File
@@ -17,7 +17,7 @@ export default function UserSearch({
displayName: a.cn ?? "",
avatarUrl: "",
openpaasId: "",
}))
})) ?? []
);
useEffect(() => {
setSelectedUsers(
@@ -5,10 +5,12 @@ import { useAppDispatch } from "../../app/hooks";
import { getCalendarRange } from "../../utils/dateUtils";
import { useAppSelector } from "../../app/hooks";
import { refreshCalendars } from "../Event/utils/eventUtils";
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
export default function CalendarLayout() {
const calendarRef = useRef<any>(null);
const dispatch = useAppDispatch();
const error = useAppSelector((state) => state.calendars.error);
const selectedCalendars = useAppSelector((state) => state.calendars.list);
const tempcalendars = useAppSelector((state) => state.calendars.templist);
const [currentDate, setCurrentDate] = useState<Date>(new Date());
@@ -62,6 +64,7 @@ export default function CalendarLayout() {
onDateChange={handleDateChange}
onViewChange={handleViewChange}
/>
<ErrorSnackbar error={error} type="calendar" />
</div>
);
}
+87 -1
View File
@@ -1,3 +1,89 @@
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import ReplayIcon from "@mui/icons-material/Replay";
import { Box, Button, Fade, Paper, Stack, Typography } from "@mui/material";
import { useEffect } from "react";
import { push } from "redux-first-history";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
export function Error() {
return <p>Error</p>;
const dispatch = useAppDispatch();
const userError = useAppSelector((state) => state.user.error);
const calendarError = useAppSelector((state) => state.calendars.error);
useEffect(() => {
if (!userError) {
dispatch(push("/"));
}
}, [calendarError, dispatch]);
const errorMessage = userError || calendarError || "Unknown error";
return (
<Fade in timeout={500}>
<Box
sx={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "background.default",
p: 3,
}}
>
<Paper
elevation={3}
sx={{
borderRadius: 4,
p: 6,
textAlign: "center",
maxWidth: 420,
width: "100%",
}}
>
<Stack spacing={2} alignItems="center">
<Box
sx={{
color: "error.main",
borderRadius: "50%",
width: 72,
height: 72,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ErrorOutlineIcon sx={{ fontSize: 40 }} />
</Box>
<Typography variant="h5" fontWeight={600}>
Something went wrong
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
{errorMessage}
</Typography>
<Button
variant="contained"
color="error"
startIcon={<ReplayIcon />}
onClick={() => {
window.location.reload();
}}
sx={{
textTransform: "none",
fontWeight: 600,
borderRadius: 2,
px: 3,
py: 1,
boxShadow: "none",
}}
>
Try Again
</Button>
</Stack>
</Paper>
</Box>
</Fade>
);
}
+42 -4
View File
@@ -1,13 +1,51 @@
import Snackbar from "@mui/material/Snackbar";
import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button";
import { useAppDispatch } from "../../app/hooks";
import { clearError as calendarClearError } from "../../features/Calendars/CalendarSlice";
import { clearError as userClearError } from "../../features/User/userSlice";
interface Props {
messages: string[];
onClose: () => void;
export function ErrorSnackbar({
error,
type,
}: {
error: string | null;
type: "user" | "calendar";
}) {
const dispatch = useAppDispatch();
const handleCloseSnackbar = () => {
dispatch(type === "calendar" ? calendarClearError() : userClearError());
};
return (
<Snackbar
open={!!error}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert
severity="error"
onClose={handleCloseSnackbar}
sx={{ width: "100%" }}
action={
<Button color="inherit" size="small" onClick={handleCloseSnackbar}>
OK
</Button>
}
>
{error}
</Alert>
</Snackbar>
);
}
export function EventErrorSnackbar({ messages, onClose }: Props) {
export function EventErrorSnackbar({
messages,
onClose,
}: {
messages: string[];
onClose: () => void;
}) {
const open = messages.length > 0;
const summary =
messages.length === 1
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -14,24 +14,24 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
const eventData = await response.text();
const eventical = ICAL.parse(eventData);
const vevents = (eventical[2] || []).filter(
([name]: [string]) => name.toLowerCase() === "vevent"
);
let targetVevent;
if (isMaster) {
// Find master VEVENT (the one without recurrence-id)
const vevents = eventical[2].filter(
([name]: string[]) => name === "vevent"
);
targetVevent = vevents.find(
([, props]: [string, any[]]) =>
!props.find(([k]: string[]) => k.toLowerCase() === "recurrence-id")
);
if (!targetVevent) {
// Fallback to first VEVENT if no master found
targetVevent = eventical[2][1];
targetVevent = vevents[0];
}
} else {
// For non-master, use first VEVENT as before
targetVevent = eventical[2][1];
targetVevent = vevents[0];
}
const eventjson = parseCalendarEvent(
+3 -3
View File
@@ -90,7 +90,7 @@ export default function EventDisplayModal({
const [end, setEnd] = useState(
formatLocalDateTime(new Date(event?.end ?? Date.now()))
);
const [allday, setAllDay] = useState(event?.allday);
const [allday, setAllDay] = useState(event?.allday ?? false);
const [repetition, setRepetition] = useState<RepetitionObject>(
event?.repetition ?? ({} as RepetitionObject)
);
@@ -280,7 +280,7 @@ export default function EventDisplayModal({
fullWidth
disabled={!isOwn}
label="Title"
value={title}
value={title ?? ""}
onChange={(e) => setTitle(e.target.value)}
size="small"
margin="dense"
@@ -366,7 +366,7 @@ export default function EventDisplayModal({
<Select
disabled={!isOwn}
labelId="calendar-select-label"
value={calendarid.toString()}
value={calendarid.toString() ?? ""}
label="Calendar"
onChange={(e: SelectChangeEvent) => {
const newId = Number(e.target.value);
+2 -2
View File
@@ -47,10 +47,10 @@ export function HandleLogin() {
initiateLogin();
}, [userData, dispatch]);
useEffect(() => {
if (!calendars.pending && !userData.loading) {
if (userData.error) {
dispatch(push("/error"));
}
if (!calendars.pending && !userData.loading) {
if (!calendars.pending && !userData.loading && !userData.error) {
dispatch(push("/calendar"));
}
}, [calendars.pending, userData.loading]);
+24 -8
View File
@@ -1,15 +1,23 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { userData, userOrganiser } from "./userDataTypes";
import { getOpenPaasUser } from "./userAPI";
import { formatReduxError } from "../../utils/errorUtils";
export const getOpenPaasUserDataAsync = createAsyncThunk<any>(
"user/getOpenPaasUserData",
async () => {
export const getOpenPaasUserDataAsync = createAsyncThunk<
Record<string, string>,
void,
{ rejectValue: { message: string; status?: number } }
>("user/getOpenPaasUserData", async (_, { rejectWithValue }) => {
try {
const user = (await getOpenPaasUser()) as Record<string, string>;
return user;
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
});
}
);
});
export const userSlice = createSlice({
name: "user",
@@ -18,6 +26,7 @@ export const userSlice = createSlice({
organiserData: null as unknown as userOrganiser,
tokens: null as unknown as Record<string, string>,
loading: true,
error: null as unknown as string | null,
},
reducers: {
setUserData: (state, action) => {
@@ -32,6 +41,9 @@ export const userSlice = createSlice({
setTokens: (state, action) => {
state.tokens = action.payload;
},
clearError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
builder
@@ -54,13 +66,17 @@ export const userSlice = createSlice({
.addCase(getOpenPaasUserDataAsync.pending, (state) => {
state.loading = true;
})
.addCase(getOpenPaasUserDataAsync.rejected, (state) => {
state.loading = false;
.addCase(getOpenPaasUserDataAsync.rejected, (state, action) => {
if (action.payload?.status !== 401) {
state.loading = false;
state.error =
action.payload?.message || "Failed to fetch user information";
}
});
},
});
// Action creators are generated for each case reducer function
export const { setUserData, setTokens } = userSlice.actions;
export const { setUserData, setTokens, clearError } = userSlice.actions;
export default userSlice.reducer;
+9
View File
@@ -0,0 +1,9 @@
export function formatReduxError(error: unknown): string {
if (!error) return "Unknown error";
if (typeof error === "string") return error;
if (typeof error === "object") {
const err = error as any;
if (err?.message) return err.message;
}
return "Unexpected error occurred";
}