#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
+78 -29
View File
@@ -4,7 +4,7 @@ import * as eventThunks from "../../src/features/Calendars/CalendarSlice";
import { renderWithProviders } from "../utils/Renderwithproviders"; import { renderWithProviders } from "../utils/Renderwithproviders";
import { searchUsers } from "../../src/features/User/userAPI"; import { searchUsers } from "../../src/features/User/userAPI";
import * as calendarThunks from "../../src/features/Calendars/CalendarSlice"; import * as calendarThunks from "../../src/features/Calendars/CalendarSlice";
import { useRef } from "react"; import { act, useRef } from "react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import CalendarLayout from "../../src/components/Calendar/CalendarLayout"; import CalendarLayout from "../../src/components/Calendar/CalendarLayout";
@@ -137,10 +137,12 @@ describe("CalendarSelection", () => {
}; };
it("renders calendars", async () => { it("renders calendars", async () => {
const mockCalendarRef = { current: null }; const mockCalendarRef = { current: null };
await act(async () => {
renderWithProviders( renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />, <CalendarApp calendarRef={mockCalendarRef} />,
preloadedState preloadedState
); );
});
expect(screen.getByText("Personnal Calendars")).toBeInTheDocument(); expect(screen.getByText("Personnal Calendars")).toBeInTheDocument();
expect(screen.getByText("Delegated Calendars")).toBeInTheDocument(); expect(screen.getByText("Delegated Calendars")).toBeInTheDocument();
expect(screen.getByText("Other Calendars")).toBeInTheDocument(); expect(screen.getByText("Other Calendars")).toBeInTheDocument();
@@ -149,12 +151,14 @@ describe("CalendarSelection", () => {
expect(screen.getByLabelText("Calendar delegated")).toBeInTheDocument(); expect(screen.getByLabelText("Calendar delegated")).toBeInTheDocument();
expect(screen.getByLabelText("Calendar shared")).toBeInTheDocument(); expect(screen.getByLabelText("Calendar shared")).toBeInTheDocument();
}); });
it("open accordeon when clicking on button only", () => { it("open accordeon when clicking on button only", async () => {
const mockCalendarRef = { current: null }; const mockCalendarRef = { current: null };
await act(async () => {
renderWithProviders( renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />, <CalendarApp calendarRef={mockCalendarRef} />,
preloadedState preloadedState
); );
});
expect(screen.getByText("Personnal Calendars")).toBeInTheDocument(); expect(screen.getByText("Personnal Calendars")).toBeInTheDocument();
expect(screen.getByText("Delegated Calendars")).toBeInTheDocument(); expect(screen.getByText("Delegated Calendars")).toBeInTheDocument();
expect(screen.getByText("Other Calendars")).toBeInTheDocument(); expect(screen.getByText("Other Calendars")).toBeInTheDocument();
@@ -168,14 +172,20 @@ describe("CalendarSelection", () => {
.closest(".MuiAccordionSummary-root"); .closest(".MuiAccordionSummary-root");
const addButton = screen.getAllByTestId("AddIcon")[2]; const addButton = screen.getAllByTestId("AddIcon")[2];
await act(async () => {
fireEvent.click(addButton); fireEvent.click(addButton);
});
expect(sharedAccordionSummary).toHaveAttribute("aria-expanded", "true"); expect(sharedAccordionSummary).toHaveAttribute("aria-expanded", "true");
await act(async () => {
fireEvent.click(addButton); fireEvent.click(addButton);
});
expect(sharedAccordionSummary).toHaveAttribute("aria-expanded", "true"); expect(sharedAccordionSummary).toHaveAttribute("aria-expanded", "true");
}); });
it("BUGFIX: remove dots in mini calendar when unselecting personnal calendar", () => { it("BUGFIX: remove dots in mini calendar when unselecting personnal calendar", async () => {
renderWithProviders(<CalendarLayout />, preloadedState); await act(async () =>
renderWithProviders(<CalendarLayout />, preloadedState)
);
const checkbox = screen.getByLabelText("Calendar personnal"); const checkbox = screen.getByLabelText("Calendar personnal");
// checkbox checked : events shown // checkbox checked : events shown
@@ -186,8 +196,9 @@ describe("CalendarSelection", () => {
).toHaveClass("event-dot"); ).toHaveClass("event-dot");
// checkbox unchecked : events hidden // checkbox unchecked : events hidden
await act(async () => {
fireEvent.click(checkbox); fireEvent.click(checkbox);
});
expect( expect(
screen.getByTestId( screen.getByTestId(
`date-${start.getFullYear()}-${start.getMonth()}-${start.getDate()}` `date-${start.getFullYear()}-${start.getMonth()}-${start.getDate()}`
@@ -195,18 +206,24 @@ describe("CalendarSelection", () => {
).not.toHaveClass("event-dot"); ).not.toHaveClass("event-dot");
// checkbox rechecked : events shown // checkbox rechecked : events shown
await act(async () => {
fireEvent.click(checkbox); fireEvent.click(checkbox);
});
expect( expect(
screen.getByTestId( screen.getByTestId(
`date-${start.getFullYear()}-${start.getMonth()}-${start.getDate()}` `date-${start.getFullYear()}-${start.getMonth()}-${start.getDate()}`
) )
).toHaveClass("event-dot"); ).toHaveClass("event-dot");
}); });
it("BUGFIX: remove dots in mini calendar when unselecting delegated calendar", () => { it("BUGFIX: remove dots in mini calendar when unselecting delegated calendar", async () => {
renderWithProviders(<CalendarLayout />, preloadedState); await act(async () =>
renderWithProviders(<CalendarLayout />, preloadedState)
);
// hide personnal event first // hide personnal event first
await act(async () => {
fireEvent.click(screen.getByLabelText("Calendar personnal")); fireEvent.click(screen.getByLabelText("Calendar personnal"));
});
const checkbox = screen.getByLabelText("Calendar delegated"); const checkbox = screen.getByLabelText("Calendar delegated");
expect( expect(
@@ -216,18 +233,24 @@ describe("CalendarSelection", () => {
).not.toHaveClass("event-dot"); ).not.toHaveClass("event-dot");
// checkbox checked : events shown // checkbox checked : events shown
await act(async () => {
fireEvent.click(checkbox); fireEvent.click(checkbox);
});
expect( expect(
screen.getByTestId( screen.getByTestId(
`date-${start.getFullYear()}-${start.getMonth()}-${start.getDate()}` `date-${start.getFullYear()}-${start.getMonth()}-${start.getDate()}`
) )
).toHaveClass("event-dot"); ).toHaveClass("event-dot");
}); });
it("BUGFIX: remove dots in mini calendar when unselecting shared calendar", () => { it("BUGFIX: remove dots in mini calendar when unselecting shared calendar", async () => {
renderWithProviders(<CalendarLayout />, preloadedState); await act(async () =>
renderWithProviders(<CalendarLayout />, preloadedState)
);
// hide personnal event first // hide personnal event first
await act(async () => {
fireEvent.click(screen.getByLabelText("Calendar personnal")); fireEvent.click(screen.getByLabelText("Calendar personnal"));
});
const checkbox = screen.getByLabelText("Calendar shared"); const checkbox = screen.getByLabelText("Calendar shared");
// checkbox unchecked : events hidden // checkbox unchecked : events hidden
@@ -238,7 +261,9 @@ describe("CalendarSelection", () => {
).not.toHaveClass("event-dot"); ).not.toHaveClass("event-dot");
// checkbox checked : events shown // checkbox checked : events shown
await act(async () => {
fireEvent.click(checkbox); fireEvent.click(checkbox);
});
expect( expect(
screen.getByTestId( screen.getByTestId(
`date-${start.getFullYear()}-${start.getMonth()}-${start.getDate()}` `date-${start.getFullYear()}-${start.getMonth()}-${start.getDate()}`
@@ -292,13 +317,18 @@ describe("calendar Availability search", () => {
}, },
]); ]);
renderWithProviders(<CalendarTestWrapper />, preloadedState); await act(async () =>
renderWithProviders(<CalendarTestWrapper />, preloadedState)
);
const input = screen.getByPlaceholderText(/start typing a name or email/i); const input = screen.getByPlaceholderText(/start typing a name or email/i);
act(() => {
userEvent.type(input, "New"); userEvent.type(input, "New");
});
const option = await screen.findByText("New User"); const option = await screen.findByText("New User");
await act(async () => {
fireEvent.click(option); fireEvent.click(option);
});
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
}); });
@@ -317,14 +347,17 @@ describe("calendar Availability search", () => {
.mockImplementation((payload) => { .mockImplementation((payload) => {
return () => Promise.resolve(payload) as any; return () => Promise.resolve(payload) as any;
}); });
renderWithProviders(<CalendarTestWrapper />, preloadedState); await act(async () =>
renderWithProviders(<CalendarTestWrapper />, preloadedState)
);
const input = screen.getByPlaceholderText(/start typing a name or email/i); const input = screen.getByPlaceholderText(/start typing a name or email/i);
userEvent.type(input, "Alice"); await act(async () => userEvent.type(input, "Alice"));
const option = await screen.findByText("Alice"); const option = await screen.findByText("Alice");
await act(async () => {
fireEvent.click(option); fireEvent.click(option);
});
expect(spy).not.toHaveBeenCalledWith(); expect(spy).not.toHaveBeenCalledWith();
}); });
@@ -343,18 +376,22 @@ describe("calendar Availability search", () => {
}, },
]); ]);
renderWithProviders(<CalendarTestWrapper />, preloadedState); await act(async () =>
renderWithProviders(<CalendarTestWrapper />, preloadedState)
);
const input = screen.getByPlaceholderText(/start typing a name or email/i); const input = screen.getByPlaceholderText(/start typing a name or email/i);
userEvent.type(input, "New"); await act(async () => userEvent.type(input, "New"));
const option = await screen.findByText("New User"); const option = await screen.findByText("New User");
await act(async () => {
fireEvent.click(option); fireEvent.click(option);
});
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /create event/i })); fireEvent.click(screen.getByRole("button", { name: /create event/i }));
});
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByText(/Create Event/i)).toHaveLength(2); expect(screen.getAllByText(/Create Event/i)).toHaveLength(2);
expect(screen.getAllByText(/New User/i)).toHaveLength(2); expect(screen.getAllByText(/New User/i)).toHaveLength(2);
@@ -376,17 +413,21 @@ describe("calendar Availability search", () => {
}, },
]); ]);
renderWithProviders(<CalendarTestWrapper />, preloadedState); await act(async () =>
renderWithProviders(<CalendarTestWrapper />, preloadedState)
);
const input = screen.getByPlaceholderText(/start typing a name or email/i); const input = screen.getByPlaceholderText(/start typing a name or email/i);
userEvent.type(input, "New"); await act(async () => userEvent.type(input, "New"));
const option = await screen.findByText("New User"); const option = await screen.findByText("New User");
await act(async () => {
fireEvent.click(option); fireEvent.click(option);
});
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
await act(async () => {
fireEvent.keyDown(input, { key: "Enter" }); fireEvent.keyDown(input, { key: "Enter" });
});
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByText(/Create Event/i)).toHaveLength(2); expect(screen.getAllByText(/Create Event/i)).toHaveLength(2);
@@ -394,14 +435,16 @@ describe("calendar Availability search", () => {
}); });
}); });
it("BUGFIX: can untoggle all personnal calendars", () => { it("BUGFIX: can untoggle all personnal calendars", async () => {
await act(async () =>
renderWithProviders(<CalendarTestWrapper />, { renderWithProviders(<CalendarTestWrapper />, {
user: preloadedState.user, user: preloadedState.user,
calendars: { calendars: {
list: { "user1/cal1": preloadedState.calendars.list["user1/cal1"] }, list: { "user1/cal1": preloadedState.calendars.list["user1/cal1"] },
pending: false, pending: false,
}, },
}); })
);
const checkbox = screen.getByLabelText("Calendar personnal"); const checkbox = screen.getByLabelText("Calendar personnal");
expect(checkbox).toBeChecked(); expect(checkbox).toBeChecked();
@@ -412,13 +455,15 @@ describe("calendar Availability search", () => {
}); });
it("BUGFIX: monthview doesn't show days numbers in banner", async () => { it("BUGFIX: monthview doesn't show days numbers in banner", async () => {
await act(async () =>
renderWithProviders(<CalendarLayout />, { renderWithProviders(<CalendarLayout />, {
user: preloadedState.user, user: preloadedState.user,
calendars: { calendars: {
list: { "user1/cal1": preloadedState.calendars.list["user1/cal1"] }, list: { "user1/cal1": preloadedState.calendars.list["user1/cal1"] },
pending: false, pending: false,
}, },
}); })
);
const calendarRef = (window as any).__calendarRef; const calendarRef = (window as any).__calendarRef;
@@ -427,8 +472,9 @@ describe("calendar Availability search", () => {
}); });
const calendarApi = calendarRef.current; const calendarApi = calendarRef.current;
await act(async () => {
calendarApi.changeView("dayGridMonth"); calendarApi.changeView("dayGridMonth");
});
await waitFor(() => { await waitFor(() => {
expect(screen.queryAllByRole("columnheader").length).toBe(14); expect(screen.queryAllByRole("columnheader").length).toBe(14);
}); });
@@ -452,7 +498,9 @@ describe("calendar Availability search", () => {
return () => Promise.resolve(payload) as any; return () => Promise.resolve(payload) as any;
}); });
jest.useFakeTimers().setSystemTime(new Date("2025-01-01")); jest.useFakeTimers().setSystemTime(new Date("2025-01-01"));
renderWithProviders(<CalendarLayout />, preloadedState); await act(async () =>
renderWithProviders(<CalendarLayout />, preloadedState)
);
await waitFor(() => { await waitFor(() => {
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
@@ -461,9 +509,10 @@ describe("calendar Availability search", () => {
const calendarRef = (window as any).__calendarRef; const calendarRef = (window as any).__calendarRef;
const calendarApi = calendarRef.current; const calendarApi = calendarRef.current;
const view = calendarApi?.view; const view = calendarApi?.view;
await act(async () => {
calendarApi.changeView("dayGridMonth"); calendarApi.changeView("dayGridMonth");
fireEvent.click(screen.getByTestId("ChevronRightIcon")); fireEvent.click(screen.getByTestId("ChevronRightIcon"));
});
expect(spy).toHaveBeenCalledTimes(4); expect(spy).toHaveBeenCalledTimes(4);
const callArgs = spy.mock.calls[3][0]; const callArgs = spy.mock.calls[3][0];
expect(callArgs.calId).toBe("user1/cal1"); expect(callArgs.calId).toBe("user1/cal1");
@@ -1,10 +1,9 @@
import { renderWithProviders } from "../utils/Renderwithproviders"; import { renderWithProviders } from "../utils/Renderwithproviders";
import { fireEvent, screen, waitFor } from "@testing-library/react"; import { screen } from "@testing-library/react";
import { jest } from "@jest/globals"; import { jest } from "@jest/globals";
import CalendarApp from "../../src/components/Calendar/Calendar"; import CalendarApp from "../../src/components/Calendar/Calendar";
import * as appHooks from "../../src/app/hooks"; import * as appHooks from "../../src/app/hooks";
import { ThunkDispatch } from "@reduxjs/toolkit"; import { ThunkDispatch } from "@reduxjs/toolkit";
import preview from "jest-preview";
describe("MiniCalendar", () => { describe("MiniCalendar", () => {
const day = new Date(); const day = new Date();
+7 -1
View File
@@ -1,4 +1,4 @@
import { screen, fireEvent, waitFor } from "@testing-library/react"; import { screen, fireEvent, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { import {
User, User,
@@ -51,7 +51,9 @@ describe("PeopleSearch", () => {
const input = screen.getByRole("combobox"); const input = screen.getByRole("combobox");
await userEvent.type(input, "Test"); await userEvent.type(input, "Test");
await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300);
});
await waitFor(() => { await waitFor(() => {
expect(mockedSearchUsers).toHaveBeenCalledWith("Test", ["user"]); expect(mockedSearchUsers).toHaveBeenCalledWith("Test", ["user"]);
@@ -64,7 +66,9 @@ describe("PeopleSearch", () => {
const input = screen.getByRole("combobox"); const input = screen.getByRole("combobox");
await userEvent.type(input, "Test"); await userEvent.type(input, "Test");
await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300);
});
const option = await screen.findByText("Test User"); const option = await screen.findByText("Test User");
await userEvent.click(option); await userEvent.click(option);
@@ -79,7 +83,9 @@ describe("PeopleSearch", () => {
setup([baseUser]); setup([baseUser]);
const input = screen.getByRole("combobox"); const input = screen.getByRole("combobox");
await userEvent.type(input, "Test"); await userEvent.type(input, "Test");
await act(async () => {
jest.advanceTimersByTime(300); jest.advanceTimersByTime(300);
});
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText("test@example.com")).not.toBeInTheDocument(); expect(screen.queryByText("test@example.com")).not.toBeInTheDocument();
@@ -37,11 +37,6 @@ describe("Calendar - Timezone Integration", () => {
timeZone: "America/New_York", timeZone: "America/New_York",
pending: false, pending: false,
}, },
events: {
selectedEvent: null,
isEditMode: false,
editModeDialogOpen: false,
},
}; };
beforeEach(() => { beforeEach(() => {
@@ -104,6 +104,26 @@ describe("Event Preview Display", () => {
}; };
it("renders correctly event data", () => { it("renders correctly event data", () => {
const originalToLocaleString = Date.prototype.toLocaleString;
jest.spyOn(Date.prototype, "toLocaleString").mockImplementation(function (
this: Date,
locales?: Intl.LocalesArgument,
options?: Intl.DateTimeFormatOptions
) {
return originalToLocaleString.call(this, "en-US", options);
});
const originalToLocaleTimeString = Date.prototype.toLocaleTimeString;
jest
.spyOn(Date.prototype, "toLocaleTimeString")
.mockImplementation(function (
this: Date,
locales?: Intl.LocalesArgument,
options?: Intl.DateTimeFormatOptions
) {
return originalToLocaleTimeString.call(this, "en-US", options);
});
renderWithProviders( renderWithProviders(
<EventPreviewModal <EventPreviewModal
open={true} open={true}
+2 -2
View File
@@ -73,9 +73,9 @@ describe("HandleLogin", () => {
expect(screen.getByAltText("loading")).toBeInTheDocument(); expect(screen.getByAltText("loading")).toBeInTheDocument();
}); });
test("goes to error page when userData doesnt exists after loading and calendars pending is false", () => { test("goes to error page when there is error in user data", () => {
const dispatch = appHooks.useAppDispatch(); const dispatch = appHooks.useAppDispatch();
renderWithProviders(<HandleLogin />, { user: { loading: false } }); renderWithProviders(<HandleLogin />, { user: { error: true } });
expect(dispatch).toHaveBeenCalledWith(push("/error")); expect(dispatch).toHaveBeenCalledWith(push("/error"));
}); });
}); });
+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 { Route, Routes } from "react-router-dom";
import { HistoryRouter as Router } from "redux-first-history/rr6"; import { HistoryRouter as Router } from "redux-first-history/rr6";
import { CallbackResume } from "./features/User/LoginCallback"; import { CallbackResume } from "./features/User/LoginCallback";
@@ -9,8 +9,18 @@ import HandleLogin from "./features/User/HandleLogin";
import CalendarLayout from "./components/Calendar/CalendarLayout"; import CalendarLayout from "./components/Calendar/CalendarLayout";
import { Error } from "./components/Error/Error"; import { Error } from "./components/Error/Error";
import { CustomThemeProvider } from "./theme/ThemeProvider"; 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() { function App() {
const error = useAppSelector((state) => state.user.error);
const dispatch = useAppDispatch();
useEffect(() => {
if (error) {
dispatch(push("/error"));
}
});
return ( return (
<CustomThemeProvider> <CustomThemeProvider>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
@@ -22,6 +32,8 @@ function App() {
<Route path="/error" element={<Error />} /> <Route path="/error" element={<Error />} />
</Routes> </Routes>
</Router> </Router>
<ErrorSnackbar error={error} type="user" />
<ErrorSnackbar error={error} type="user" />
</Suspense> </Suspense>
</CustomThemeProvider> </CustomThemeProvider>
); );
+1 -1
View File
@@ -17,7 +17,7 @@ export default function UserSearch({
displayName: a.cn ?? "", displayName: a.cn ?? "",
avatarUrl: "", avatarUrl: "",
openpaasId: "", openpaasId: "",
})) })) ?? []
); );
useEffect(() => { useEffect(() => {
setSelectedUsers( setSelectedUsers(
@@ -5,10 +5,12 @@ import { useAppDispatch } from "../../app/hooks";
import { getCalendarRange } from "../../utils/dateUtils"; import { getCalendarRange } from "../../utils/dateUtils";
import { useAppSelector } from "../../app/hooks"; import { useAppSelector } from "../../app/hooks";
import { refreshCalendars } from "../Event/utils/eventUtils"; import { refreshCalendars } from "../Event/utils/eventUtils";
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
export default function CalendarLayout() { export default function CalendarLayout() {
const calendarRef = useRef<any>(null); const calendarRef = useRef<any>(null);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const error = useAppSelector((state) => state.calendars.error);
const selectedCalendars = useAppSelector((state) => state.calendars.list); const selectedCalendars = useAppSelector((state) => state.calendars.list);
const tempcalendars = useAppSelector((state) => state.calendars.templist); const tempcalendars = useAppSelector((state) => state.calendars.templist);
const [currentDate, setCurrentDate] = useState<Date>(new Date()); const [currentDate, setCurrentDate] = useState<Date>(new Date());
@@ -62,6 +64,7 @@ export default function CalendarLayout() {
onDateChange={handleDateChange} onDateChange={handleDateChange}
onViewChange={handleViewChange} onViewChange={handleViewChange}
/> />
<ErrorSnackbar error={error} type="calendar" />
</div> </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() { 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 Snackbar from "@mui/material/Snackbar";
import Alert from "@mui/material/Alert"; import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button"; 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 { export function ErrorSnackbar({
messages: string[]; error,
onClose: () => void; 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 open = messages.length > 0;
const summary = const summary =
messages.length === 1 messages.length === 1
+408 -52
View File
@@ -29,15 +29,25 @@ import {
import { User } from "../../components/Attendees/PeopleSearch"; import { User } from "../../components/Attendees/PeopleSearch";
import { getCalendarVisibility } from "../../components/Calendar/utils/calendarUtils"; import { getCalendarVisibility } from "../../components/Calendar/utils/calendarUtils";
import { importFile } from "../../utils/apiUtils"; import { importFile } from "../../utils/apiUtils";
import { formatReduxError } from "../../utils/errorUtils";
// Define error type for rejected actions
interface RejectedError {
message: string;
status?: number;
}
export const getCalendarsListAsync = createAsyncThunk< export const getCalendarsListAsync = createAsyncThunk<
Record<string, Calendars> // Return type { importedCalendars: Record<string, Calendars>; errors: string }, // Return type
>("calendars/getCalendars", async () => { void, // Arg type
{ rejectValue: RejectedError } // ThunkAPI config
>("calendars/getCalendars", async (_, { rejectWithValue }) => {
try {
const importedCalendars: Record<string, Calendars> = {}; const importedCalendars: Record<string, Calendars> = {};
const user = (await getOpenPaasUser()) as Record<string, string>; const user = (await getOpenPaasUser()) as Record<string, string>;
const calendars = (await getCalendars(user.id)) as Record<string, any>; const calendars = (await getCalendars(user.id)) as Record<string, any>;
const rawCalendars = calendars._embedded["dav:calendar"]; const rawCalendars = calendars._embedded["dav:calendar"];
const errors = [];
for (const cal of rawCalendars) { for (const cal of rawCalendars) {
const description = cal["caldav:description"]; const description = cal["caldav:description"];
let delegated = false; let delegated = false;
@@ -52,7 +62,24 @@ export const getCalendarsListAsync = createAsyncThunk<
const id = source.replace("/calendars/", "").replace(".json", ""); const id = source.replace("/calendars/", "").replace(".json", "");
const ownerId = id.split("/")[0]; const ownerId = id.split("/")[0];
const visibility = getCalendarVisibility(cal["acl"]); const visibility = getCalendarVisibility(cal["acl"]);
const ownerData: any = await getUserDetails(ownerId);
// Safely fetch owner data with fallback
let ownerData: any;
try {
ownerData = await getUserDetails(ownerId);
} catch (error) {
console.error(
`Failed to fetch user details for ${id.split("/")[0]}:`,
error
);
// Provide fallback data
ownerData = {
firstname: "",
lastname: "Unknown User",
emails: [],
};
errors.push(error);
}
const name = const name =
ownerId !== user.id && cal["dav:name"] === "#default" ownerId !== user.id && cal["dav:name"] === "#default"
? `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ ? `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${
@@ -80,13 +107,21 @@ export const getCalendarsListAsync = createAsyncThunk<
}; };
} }
return importedCalendars; return { importedCalendars, errors: errors.join("\n") };
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
});
}
}); });
export const getTempCalendarsListAsync = createAsyncThunk< export const getTempCalendarsListAsync = createAsyncThunk<
Record<string, Calendars>, Record<string, Calendars>,
User User,
>("calendars/getTempCalendars", async (tempUser) => { { rejectValue: RejectedError }
>("calendars/getTempCalendars", async (tempUser, { rejectWithValue }) => {
try {
const importedCalendars: Record<string, Calendars> = {}; const importedCalendars: Record<string, Calendars> = {};
const calendars = (await getCalendars( const calendars = (await getCalendars(
@@ -126,36 +161,65 @@ export const getTempCalendarsListAsync = createAsyncThunk<
} }
return importedCalendars; return importedCalendars;
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
});
}
}); });
export const getCalendarDetailAsync = createAsyncThunk< export const getCalendarDetailAsync = createAsyncThunk<
{ calId: string; events: CalendarEvent[]; calType?: string }, // Return type { calId: string; events: CalendarEvent[]; calType?: string },
{ calId: string; match: { start: string; end: string }; calType?: string } // Arg type { calId: string; match: { start: string; end: string }; calType?: string },
>("calendars/getCalendarDetails", async ({ calId, match, calType }) => { { rejectValue: RejectedError }
>(
"calendars/getCalendarDetails",
async ({ calId, match, calType }, { rejectWithValue }) => {
try {
const calendar = (await getCalendar(calId, match)) as Record<string, any>; const calendar = (await getCalendar(calId, match)) as Record<string, any>;
const color = calendar["apple:color"]; const color = calendar["apple:color"];
const events: CalendarEvent[] = calendar._embedded["dav:item"].flatMap( const events: CalendarEvent[] = calendar._embedded["dav:item"].flatMap(
(eventdata: any) => { (eventdata: any) => {
const vevents = eventdata.data[2] as any[][]; // array of ['vevent', RawEntry[], []] const vevents = eventdata.data[2] as any[][];
const valarm = eventdata.data[2][0][2][0]; const valarm = eventdata.data[2][0][2][0];
const eventURL = eventdata._links.self.href; const eventURL = eventdata._links.self.href;
return vevents.map((vevent: any[]) => { return vevents.map((vevent: any[]) => {
return parseCalendarEvent(vevent[1], color, calId, eventURL, valarm); return parseCalendarEvent(
vevent[1],
color,
calId,
eventURL,
valarm
);
}); });
} }
); );
return { calId, events, calType }; return { calId, events, calType };
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
}); });
}
}
);
export const putEventAsync = createAsyncThunk< export const putEventAsync = createAsyncThunk<
{ calId: string; events: CalendarEvent[]; calType?: "temp" }, // Return type { calId: string; events: CalendarEvent[]; calType?: "temp" },
{ cal: Calendars; newEvent: CalendarEvent; calType?: "temp" } // Arg type { cal: Calendars; newEvent: CalendarEvent; calType?: "temp" },
>("calendars/putEvent", async ({ cal, newEvent, calType }) => { { rejectValue: RejectedError }
await putEvent(newEvent, cal.ownerEmails ? cal.ownerEmails[0] : undefined); >(
"calendars/putEvent",
async ({ cal, newEvent, calType }, { rejectWithValue }) => {
try {
await putEvent(
newEvent,
cal.ownerEmails ? cal.ownerEmails[0] : undefined
);
const eventDate = new Date(newEvent.start); const eventDate = new Date(newEvent.start);
// Calculate week range based on Monday as first day (consistent with FullCalendar firstDay={1})
const { start: weekStart, end: weekEnd } = computeWeekRange(eventDate); const { start: weekStart, end: weekEnd } = computeWeekRange(eventDate);
const calEvents = (await getCalendar(cal.id, { const calEvents = (await getCalendar(cal.id, {
@@ -184,37 +248,64 @@ export const putEventAsync = createAsyncThunk<
events, events,
calType, calType,
}; };
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
}); });
}
}
);
export const getEventAsync = createAsyncThunk< export const getEventAsync = createAsyncThunk<
{ calId: string; event: CalendarEvent }, // Return type { calId: string; event: CalendarEvent },
CalendarEvent // Arg type CalendarEvent,
>("calendars/getEvent", async (event) => { { rejectValue: RejectedError }
>("calendars/getEvent", async (event, { rejectWithValue }) => {
try {
const response: CalendarEvent = await getEvent(event); const response: CalendarEvent = await getEvent(event);
return { return {
calId: event.calId, calId: event.calId,
event: response, event: response,
}; };
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
}); });
}
});
export const patchCalendarAsync = createAsyncThunk< export const patchCalendarAsync = createAsyncThunk<
{ {
calId: string; calId: string;
calLink: string; calLink: string;
patch: { name: string; desc: string; color: Record<string, string> }; patch: { name: string; desc: string; color: Record<string, string> };
}, // Return type },
{ {
calId: string; calId: string;
calLink: string; calLink: string;
patch: { name: string; desc: string; color: Record<string, string> }; patch: { name: string; desc: string; color: Record<string, string> };
} // Arg type },
>("calendars/patchCalendar", async ({ calId, calLink, patch }) => { { rejectValue: RejectedError }
>(
"calendars/patchCalendar",
async ({ calId, calLink, patch }, { rejectWithValue }) => {
try {
await proppatchCalendar(calLink, patch); await proppatchCalendar(calLink, patch);
return { return {
calId, calId,
calLink, calLink,
patch, patch,
}; };
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
}); });
}
}
);
export const removeCalendarAsync = createAsyncThunk< export const removeCalendarAsync = createAsyncThunk<
{ {
@@ -223,22 +314,36 @@ export const removeCalendarAsync = createAsyncThunk<
{ {
calId: string; calId: string;
calLink: string; calLink: string;
} },
>("calendars/removeCalendar", async ({ calId, calLink }) => { { rejectValue: RejectedError }
>(
"calendars/removeCalendar",
async ({ calId, calLink }, { rejectWithValue }) => {
try {
await removeCalendar(calLink); await removeCalendar(calLink);
return { return {
calId, calId,
calLink, calLink,
}; };
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
}); });
}
}
);
export const moveEventAsync = createAsyncThunk< export const moveEventAsync = createAsyncThunk<
{ calId: string; events: CalendarEvent[] }, // Return type { calId: string; events: CalendarEvent[] },
{ cal: Calendars; newEvent: CalendarEvent; newURL: string } // Arg type { cal: Calendars; newEvent: CalendarEvent; newURL: string },
>("calendars/moveEvent", async ({ cal, newEvent, newURL }) => { { rejectValue: RejectedError }
>(
"calendars/moveEvent",
async ({ cal, newEvent, newURL }, { rejectWithValue }) => {
try {
await moveEvent(newEvent, newURL); await moveEvent(newEvent, newURL);
// Calculate week range based on Monday as first day (consistent with FullCalendar firstDay={1})
const eventDate = new Date(newEvent.start); const eventDate = new Date(newEvent.start);
const { start: weekStart, end: weekEnd } = computeWeekRange(eventDate); const { start: weekStart, end: weekEnd } = computeWeekRange(eventDate);
@@ -251,7 +356,12 @@ export const moveEventAsync = createAsyncThunk<
const vevents = eventdata.data[2] as any[][]; const vevents = eventdata.data[2] as any[][];
const eventURL = eventdata._links.self.href; const eventURL = eventdata._links.self.href;
return vevents.map((vevent: any[]) => { return vevents.map((vevent: any[]) => {
return parseCalendarEvent(vevent[1], cal.color ?? {}, cal.id, eventURL); return parseCalendarEvent(
vevent[1],
cal.color ?? {},
cal.id,
eventURL
);
}); });
} }
); );
@@ -260,58 +370,117 @@ export const moveEventAsync = createAsyncThunk<
calId: cal.id, calId: cal.id,
events, events,
}; };
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
}); });
}
}
);
export const patchACLCalendarAsync = createAsyncThunk< export const patchACLCalendarAsync = createAsyncThunk<
{ {
calId: string; calId: string;
calLink: string; calLink: string;
request: string; request: string;
}, // Return type },
{ {
calId: string; calId: string;
calLink: string; calLink: string;
request: string; request: string;
} // Arg type },
>("calendars/requestACLCalendar", async ({ calId, calLink, request }) => { { rejectValue: RejectedError }
>(
"calendars/requestACLCalendar",
async ({ calId, calLink, request }, { rejectWithValue }) => {
try {
const response = await updateAclCalendar(calLink, request); const response = await updateAclCalendar(calLink, request);
return { return {
calId, calId,
calLink, calLink,
request, request,
}; };
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
}); });
}
}
);
export const deleteEventAsync = createAsyncThunk< export const deleteEventAsync = createAsyncThunk<
{ calId: string; eventId: string }, // Return type { calId: string; eventId: string },
{ calId: string; eventId: string; eventURL: string } // Arg type { calId: string; eventId: string; eventURL: string },
>("calendars/delEvent", async ({ calId, eventId, eventURL }) => { { rejectValue: RejectedError }
>(
"calendars/delEvent",
async ({ calId, eventId, eventURL }, { rejectWithValue }) => {
try {
await deleteEvent(eventURL); await deleteEvent(eventURL);
return { calId, eventId }; return { calId, eventId };
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
}); });
}
}
);
export const deleteEventInstanceAsync = createAsyncThunk< export const deleteEventInstanceAsync = createAsyncThunk<
{ calId: string; eventId: string }, { calId: string; eventId: string },
{ cal: Calendars; event: CalendarEvent } { cal: Calendars; event: CalendarEvent },
>("calendars/delEventInstance", async ({ cal, event }) => { { rejectValue: RejectedError }
>("calendars/delEventInstance", async ({ cal, event }, { rejectWithValue }) => {
try {
await deleteEventInstance(event, cal.ownerEmails?.[0]); await deleteEventInstance(event, cal.ownerEmails?.[0]);
return { calId: cal.id, eventId: event.uid }; return { calId: cal.id, eventId: event.uid };
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
});
}
}); });
export const updateEventInstanceAsync = createAsyncThunk< export const updateEventInstanceAsync = createAsyncThunk<
{ calId: string; event: CalendarEvent }, { calId: string; event: CalendarEvent },
{ cal: Calendars; event: CalendarEvent } { cal: Calendars; event: CalendarEvent },
>("calendars/updateEventInstance", async ({ cal, event }) => { { rejectValue: RejectedError }
>(
"calendars/updateEventInstance",
async ({ cal, event }, { rejectWithValue }) => {
try {
await putEventWithOverrides(event, cal.ownerEmails?.[0]); await putEventWithOverrides(event, cal.ownerEmails?.[0]);
return { calId: cal.id, event }; return { calId: cal.id, event };
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
}); });
}
}
);
export const updateSeriesAsync = createAsyncThunk< export const updateSeriesAsync = createAsyncThunk<
void, void,
{ cal: Calendars; event: CalendarEvent; removeOverrides?: boolean } { cal: Calendars; event: CalendarEvent; removeOverrides?: boolean },
>("calendars/updateSeries", async ({ cal, event, removeOverrides = true }) => { { rejectValue: RejectedError }
>(
"calendars/updateSeries",
async ({ cal, event, removeOverrides = true }, { rejectWithValue }) => {
try {
await updateSeries(event, cal.ownerEmails?.[0], removeOverrides); await updateSeries(event, cal.ownerEmails?.[0], removeOverrides);
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
}); });
}
}
);
export const createCalendarAsync = createAsyncThunk< export const createCalendarAsync = createAsyncThunk<
{ {
@@ -322,15 +491,19 @@ export const createCalendarAsync = createAsyncThunk<
desc: string; desc: string;
owner: string; owner: string;
ownerEmails: string[]; ownerEmails: string[];
}, // Return type },
{ {
userId: string; userId: string;
calId: string; calId: string;
color: Record<string, string>; color: Record<string, string>;
name: string; name: string;
desc: string; desc: string;
} // Arg type },
>("calendars/createCalendar", async ({ userId, calId, color, name, desc }) => { { rejectValue: RejectedError }
>(
"calendars/createCalendar",
async ({ userId, calId, color, name, desc }, { rejectWithValue }) => {
try {
await postCalendar(userId, calId, color, name, desc); await postCalendar(userId, calId, color, name, desc);
const ownerData: any = await getUserDetails(userId.split("/")[0]); const ownerData: any = await getUserDetails(userId.split("/")[0]);
@@ -345,7 +518,14 @@ export const createCalendarAsync = createAsyncThunk<
}`, }`,
ownerEmails: ownerData.emails, ownerEmails: ownerData.emails,
}; };
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
}); });
}
}
);
export const addSharedCalendarAsync = createAsyncThunk< export const addSharedCalendarAsync = createAsyncThunk<
{ {
@@ -356,9 +536,13 @@ export const addSharedCalendarAsync = createAsyncThunk<
desc: string; desc: string;
owner: string; owner: string;
ownerEmails: string[]; ownerEmails: string[];
}, // Return type },
{ userId: string; calId: string; cal: Record<string, any> } // Arg type { userId: string; calId: string; cal: Record<string, any> },
>("calendars/addSharedCalendar", async ({ userId, calId, cal }) => { { rejectValue: RejectedError }
>(
"calendars/addSharedCalendar",
async ({ userId, calId, cal }, { rejectWithValue }) => {
try {
await addSharedCalendar(userId, calId, cal); await addSharedCalendar(userId, calId, cal);
const ownerData: any = await getUserDetails( const ownerData: any = await getUserDetails(
cal.cal._links.self.href cal.cal._links.self.href
@@ -388,17 +572,32 @@ export const addSharedCalendarAsync = createAsyncThunk<
}`, }`,
ownerEmails: ownerData.emails, ownerEmails: ownerData.emails,
}; };
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
}); });
}
}
);
export const importEventFromFileAsync = createAsyncThunk< export const importEventFromFileAsync = createAsyncThunk<
void, void,
{ {
calLink: string; calLink: string;
file: File; file: File;
} },
>("calendars/importEvent", async ({ calLink, file }) => { { rejectValue: RejectedError }
>("calendars/importEvent", async ({ calLink, file }, { rejectWithValue }) => {
try {
const id = ((await importFile(file)) as Record<string, string>)._id; const id = ((await importFile(file)) as Record<string, string>)._id;
const response = await importEventFromFile(id, calLink); const response = await importEventFromFile(id, calLink);
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
});
}
}); });
const CalendarSlice = createSlice({ const CalendarSlice = createSlice({
@@ -407,11 +606,13 @@ const CalendarSlice = createSlice({
list: {} as Record<string, Calendars>, list: {} as Record<string, Calendars>,
templist: {} as Record<string, Calendars>, templist: {} as Record<string, Calendars>,
pending: false, pending: false,
error: null as string | null,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
} as { } as {
list: Record<string, Calendars>; list: Record<string, Calendars>;
templist: Record<string, Calendars>; templist: Record<string, Calendars>;
pending: boolean; pending: boolean;
error: string | null;
timeZone: string; timeZone: string;
}, },
reducers: { reducers: {
@@ -480,6 +681,9 @@ const CalendarSlice = createSlice({
if (!state.list[action.payload]) return; if (!state.list[action.payload]) return;
state.list[action.payload].lastCacheCleared = Date.now(); state.list[action.payload].lastCacheCleared = Date.now();
}, },
clearError: (state) => {
state.error = null;
},
updateCalColor: ( updateCalColor: (
state, state,
action: PayloadAction<{ action: PayloadAction<{
@@ -492,11 +696,21 @@ const CalendarSlice = createSlice({
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder builder
// Fulfilled cases
.addCase( .addCase(
getCalendarsListAsync.fulfilled, getCalendarsListAsync.fulfilled,
(state, action: PayloadAction<Record<string, Calendars>>) => { (
state,
action: PayloadAction<{
importedCalendars: Record<string, Calendars>;
errors: string;
}>
) => {
state.pending = false; state.pending = false;
state.list = action.payload; state.list = action.payload.importedCalendars;
state.error = action.payload.errors.length
? action.payload.errors
: null;
} }
) )
.addCase( .addCase(
@@ -636,18 +850,22 @@ const CalendarSlice = createSlice({
action.payload.eventId action.payload.eventId
]; ];
} }
state.error = null;
}) })
.addCase(deleteEventInstanceAsync.fulfilled, (state, action) => { .addCase(deleteEventInstanceAsync.fulfilled, (state, action) => {
state.pending = false; state.pending = false;
delete state.list[action.payload.calId].events[action.payload.eventId]; delete state.list[action.payload.calId].events[action.payload.eventId];
state.error = null;
}) })
.addCase(updateEventInstanceAsync.fulfilled, (state, action) => { .addCase(updateEventInstanceAsync.fulfilled, (state, action) => {
state.pending = false; state.pending = false;
state.list[action.payload.calId].events[action.payload.event.uid] = state.list[action.payload.calId].events[action.payload.event.uid] =
action.payload.event; action.payload.event;
state.error = null;
}) })
.addCase(updateSeriesAsync.fulfilled, (state) => { .addCase(updateSeriesAsync.fulfilled, (state) => {
state.pending = false; state.pending = false;
state.error = null;
}) })
.addCase(createCalendarAsync.fulfilled, (state, action) => { .addCase(createCalendarAsync.fulfilled, (state, action) => {
state.pending = false; state.pending = false;
@@ -661,6 +879,7 @@ const CalendarSlice = createSlice({
ownerEmails: action.payload.ownerEmails, ownerEmails: action.payload.ownerEmails,
events: {}, events: {},
} as Calendars; } as Calendars;
state.error = null;
}) })
.addCase(patchCalendarAsync.fulfilled, (state, action) => { .addCase(patchCalendarAsync.fulfilled, (state, action) => {
state.pending = false; state.pending = false;
@@ -687,6 +906,7 @@ const CalendarSlice = createSlice({
name: action.payload.patch.name, name: action.payload.patch.name,
}; };
} }
state.error = null;
}) })
.addCase(addSharedCalendarAsync.fulfilled, (state, action) => { .addCase(addSharedCalendarAsync.fulfilled, (state, action) => {
state.pending = false; state.pending = false;
@@ -700,16 +920,24 @@ const CalendarSlice = createSlice({
owner: action.payload.owner, owner: action.payload.owner,
ownerEmails: action.payload.ownerEmails, ownerEmails: action.payload.ownerEmails,
} as Calendars; } as Calendars;
state.error = null;
}) })
.addCase(removeCalendarAsync.fulfilled, (state, action) => { .addCase(removeCalendarAsync.fulfilled, (state, action) => {
state.pending = false; state.pending = false;
delete state.list[action.payload.calId]; delete state.list[action.payload.calId];
state.error = null;
}) })
.addCase(patchACLCalendarAsync.fulfilled, (state, action) => { .addCase(patchACLCalendarAsync.fulfilled, (state, action) => {
state.pending = false; state.pending = false;
state.list[action.payload.calId].visibility = state.list[action.payload.calId].visibility =
action.payload.request !== "" ? "public" : "private"; action.payload.request !== "" ? "public" : "private";
state.error = null;
}) })
.addCase(importEventFromFileAsync.fulfilled, (state) => {
state.pending = false;
state.error = null;
})
// Pending cases
.addCase(getCalendarDetailAsync.pending, (state) => { .addCase(getCalendarDetailAsync.pending, (state) => {
state.pending = true; state.pending = true;
}) })
@@ -728,6 +956,15 @@ const CalendarSlice = createSlice({
.addCase(deleteEventAsync.pending, (state) => { .addCase(deleteEventAsync.pending, (state) => {
state.pending = true; state.pending = true;
}) })
.addCase(deleteEventInstanceAsync.pending, (state) => {
state.pending = true;
})
.addCase(updateEventInstanceAsync.pending, (state) => {
state.pending = true;
})
.addCase(updateSeriesAsync.pending, (state) => {
state.pending = true;
})
.addCase(patchCalendarAsync.pending, (state) => { .addCase(patchCalendarAsync.pending, (state) => {
state.pending = true; state.pending = true;
}) })
@@ -745,6 +982,124 @@ const CalendarSlice = createSlice({
}) })
.addCase(patchACLCalendarAsync.pending, (state) => { .addCase(patchACLCalendarAsync.pending, (state) => {
state.pending = true; state.pending = true;
})
.addCase(importEventFromFileAsync.pending, (state) => {
state.pending = true;
})
// Rejected cases
.addCase(getCalendarsListAsync.rejected, (state, action) => {
if (action.payload?.status !== 401) {
state.pending = false;
state.error =
action.payload?.message ||
action.error.message ||
"Failed to load calendars";
}
})
.addCase(getTempCalendarsListAsync.rejected, (state, action) => {
state.pending = false;
state.error =
action.payload?.message ||
action.error.message ||
"Failed to load temporary calendars";
})
.addCase(getCalendarDetailAsync.rejected, (state, action) => {
state.pending = false;
state.error =
action.payload?.message ||
action.error.message ||
"Failed to load calendar details";
})
.addCase(putEventAsync.rejected, (state, action) => {
state.pending = false;
state.error =
action.payload?.message ||
action.error.message ||
"Failed to create event";
})
.addCase(getEventAsync.rejected, (state, action) => {
state.pending = false;
state.error =
action.payload?.message ||
action.error.message ||
"Failed to load event";
})
.addCase(moveEventAsync.rejected, (state, action) => {
state.pending = false;
state.error =
action.payload?.message ||
action.error.message ||
"Failed to move event";
})
.addCase(deleteEventAsync.rejected, (state, action) => {
state.pending = false;
state.error =
action.payload?.message ||
action.error.message ||
"Failed to delete event";
})
.addCase(deleteEventInstanceAsync.rejected, (state, action) => {
state.pending = false;
state.error =
action.payload?.message ||
action.error.message ||
"Failed to delete event instance";
})
.addCase(updateEventInstanceAsync.rejected, (state, action) => {
state.pending = false;
state.error =
action.payload?.message ||
action.error.message ||
"Failed to update event instance";
})
.addCase(updateSeriesAsync.rejected, (state, action) => {
state.pending = false;
state.error =
action.payload?.message ||
action.error.message ||
"Failed to update event series";
})
.addCase(patchCalendarAsync.rejected, (state, action) => {
state.pending = false;
state.error =
action.payload?.message ||
action.error.message ||
"Failed to update calendar";
})
.addCase(createCalendarAsync.rejected, (state, action) => {
state.pending = false;
state.error =
action.payload?.message ||
action.error.message ||
"Failed to create calendar";
})
.addCase(addSharedCalendarAsync.rejected, (state, action) => {
state.pending = false;
state.error =
action.payload?.message ||
action.error.message ||
"Failed to add shared calendar";
})
.addCase(removeCalendarAsync.rejected, (state, action) => {
state.pending = false;
state.error =
action.payload?.message ||
action.error.message ||
"Failed to remove calendar";
})
.addCase(patchACLCalendarAsync.rejected, (state, action) => {
state.pending = false;
state.error =
action.payload?.message ||
action.error.message ||
"Failed to update calendar permissions";
})
.addCase(importEventFromFileAsync.rejected, (state, action) => {
state.pending = false;
state.error =
action.payload?.message ||
action.error.message ||
"Failed to import event from file";
}); });
}, },
}); });
@@ -758,6 +1113,7 @@ export const {
emptyEventsCal, emptyEventsCal,
setTimeZone, setTimeZone,
clearFetchCache, clearFetchCache,
clearError,
updateCalColor, updateCalColor,
} = CalendarSlice.actions; } = CalendarSlice.actions;
export default CalendarSlice.reducer; export default CalendarSlice.reducer;
+6 -6
View File
@@ -14,24 +14,24 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
const eventData = await response.text(); const eventData = await response.text();
const eventical = ICAL.parse(eventData); const eventical = ICAL.parse(eventData);
const vevents = (eventical[2] || []).filter(
([name]: [string]) => name.toLowerCase() === "vevent"
);
let targetVevent; let targetVevent;
if (isMaster) { if (isMaster) {
// Find master VEVENT (the one without recurrence-id) // Find master VEVENT (the one without recurrence-id)
const vevents = eventical[2].filter(
([name]: string[]) => name === "vevent"
);
targetVevent = vevents.find( targetVevent = vevents.find(
([, props]: [string, any[]]) => ([, props]: [string, any[]]) =>
!props.find(([k]: string[]) => k.toLowerCase() === "recurrence-id") !props.find(([k]: string[]) => k.toLowerCase() === "recurrence-id")
); );
if (!targetVevent) { if (!targetVevent) {
// Fallback to first VEVENT if no master found // Fallback to first VEVENT if no master found
targetVevent = eventical[2][1]; targetVevent = vevents[0];
} }
} else { } else {
// For non-master, use first VEVENT as before // For non-master, use first VEVENT as before
targetVevent = eventical[2][1]; targetVevent = vevents[0];
} }
const eventjson = parseCalendarEvent( const eventjson = parseCalendarEvent(
+3 -3
View File
@@ -90,7 +90,7 @@ export default function EventDisplayModal({
const [end, setEnd] = useState( const [end, setEnd] = useState(
formatLocalDateTime(new Date(event?.end ?? Date.now())) 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>( const [repetition, setRepetition] = useState<RepetitionObject>(
event?.repetition ?? ({} as RepetitionObject) event?.repetition ?? ({} as RepetitionObject)
); );
@@ -280,7 +280,7 @@ export default function EventDisplayModal({
fullWidth fullWidth
disabled={!isOwn} disabled={!isOwn}
label="Title" label="Title"
value={title} value={title ?? ""}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
size="small" size="small"
margin="dense" margin="dense"
@@ -366,7 +366,7 @@ export default function EventDisplayModal({
<Select <Select
disabled={!isOwn} disabled={!isOwn}
labelId="calendar-select-label" labelId="calendar-select-label"
value={calendarid.toString()} value={calendarid.toString() ?? ""}
label="Calendar" label="Calendar"
onChange={(e: SelectChangeEvent) => { onChange={(e: SelectChangeEvent) => {
const newId = Number(e.target.value); const newId = Number(e.target.value);
+2 -2
View File
@@ -47,10 +47,10 @@ export function HandleLogin() {
initiateLogin(); initiateLogin();
}, [userData, dispatch]); }, [userData, dispatch]);
useEffect(() => { useEffect(() => {
if (!calendars.pending && !userData.loading) { if (userData.error) {
dispatch(push("/error")); dispatch(push("/error"));
} }
if (!calendars.pending && !userData.loading) { if (!calendars.pending && !userData.loading && !userData.error) {
dispatch(push("/calendar")); dispatch(push("/calendar"));
} }
}, [calendars.pending, userData.loading]); }, [calendars.pending, userData.loading]);
+23 -7
View File
@@ -1,15 +1,23 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { userData, userOrganiser } from "./userDataTypes"; import { userData, userOrganiser } from "./userDataTypes";
import { getOpenPaasUser } from "./userAPI"; import { getOpenPaasUser } from "./userAPI";
import { formatReduxError } from "../../utils/errorUtils";
export const getOpenPaasUserDataAsync = createAsyncThunk<any>( export const getOpenPaasUserDataAsync = createAsyncThunk<
"user/getOpenPaasUserData", Record<string, string>,
async () => { void,
{ rejectValue: { message: string; status?: number } }
>("user/getOpenPaasUserData", async (_, { rejectWithValue }) => {
try {
const user = (await getOpenPaasUser()) as Record<string, string>; const user = (await getOpenPaasUser()) as Record<string, string>;
return user; return user;
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
});
} }
); });
export const userSlice = createSlice({ export const userSlice = createSlice({
name: "user", name: "user",
@@ -18,6 +26,7 @@ export const userSlice = createSlice({
organiserData: null as unknown as userOrganiser, organiserData: null as unknown as userOrganiser,
tokens: null as unknown as Record<string, string>, tokens: null as unknown as Record<string, string>,
loading: true, loading: true,
error: null as unknown as string | null,
}, },
reducers: { reducers: {
setUserData: (state, action) => { setUserData: (state, action) => {
@@ -32,6 +41,9 @@ export const userSlice = createSlice({
setTokens: (state, action) => { setTokens: (state, action) => {
state.tokens = action.payload; state.tokens = action.payload;
}, },
clearError: (state) => {
state.error = null;
},
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder builder
@@ -54,13 +66,17 @@ export const userSlice = createSlice({
.addCase(getOpenPaasUserDataAsync.pending, (state) => { .addCase(getOpenPaasUserDataAsync.pending, (state) => {
state.loading = true; state.loading = true;
}) })
.addCase(getOpenPaasUserDataAsync.rejected, (state) => { .addCase(getOpenPaasUserDataAsync.rejected, (state, action) => {
if (action.payload?.status !== 401) {
state.loading = false; state.loading = false;
state.error =
action.payload?.message || "Failed to fetch user information";
}
}); });
}, },
}); });
// Action creators are generated for each case reducer function // 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; 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";
}