From 42f5d8e1a9bd8f90b102b8f7cf12848ac4b2188a Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Mon, 27 Oct 2025 11:23:38 +0100 Subject: [PATCH] #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 --- __test__/components/Calendar.test.tsx | 181 ++-- .../components/MiniCalendarColor.test.tsx | 3 +- __test__/components/PeopleSearch.test.tsx | 14 +- .../Calendars/TimezoneInGrid.test.tsx | 5 - .../features/Events/EventDisplay.test.tsx | 20 + __test__/features/user/HandleLogin.test.tsx | 4 +- src/App.tsx | 14 +- src/components/Attendees/AttendeeSearch.tsx | 2 +- src/components/Calendar/CalendarLayout.tsx | 3 + src/components/Error/Error.tsx | 88 +- src/components/Error/ErrorSnackbar.tsx | 46 +- src/features/Calendars/CalendarSlice.ts | 888 ++++++++++++------ src/features/Events/EventApi.ts | 12 +- src/features/Events/EventDisplay.tsx | 6 +- src/features/User/HandleLogin.tsx | 4 +- src/features/User/userSlice.ts | 32 +- src/utils/errorUtils.ts | 9 + 17 files changed, 960 insertions(+), 371 deletions(-) create mode 100644 src/utils/errorUtils.ts diff --git a/__test__/components/Calendar.test.tsx b/__test__/components/Calendar.test.tsx index e43313e..04ff24e 100644 --- a/__test__/components/Calendar.test.tsx +++ b/__test__/components/Calendar.test.tsx @@ -4,7 +4,7 @@ import * as eventThunks from "../../src/features/Calendars/CalendarSlice"; import { renderWithProviders } from "../utils/Renderwithproviders"; import { searchUsers } from "../../src/features/User/userAPI"; 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 CalendarLayout from "../../src/components/Calendar/CalendarLayout"; @@ -137,10 +137,12 @@ describe("CalendarSelection", () => { }; it("renders calendars", async () => { const mockCalendarRef = { current: null }; - renderWithProviders( - , - preloadedState - ); + await act(async () => { + renderWithProviders( + , + preloadedState + ); + }); expect(screen.getByText("Personnal Calendars")).toBeInTheDocument(); expect(screen.getByText("Delegated Calendars")).toBeInTheDocument(); expect(screen.getByText("Other Calendars")).toBeInTheDocument(); @@ -149,12 +151,14 @@ describe("CalendarSelection", () => { expect(screen.getByLabelText("Calendar delegated")).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 }; - renderWithProviders( - , - preloadedState - ); + await act(async () => { + renderWithProviders( + , + preloadedState + ); + }); expect(screen.getByText("Personnal Calendars")).toBeInTheDocument(); expect(screen.getByText("Delegated Calendars")).toBeInTheDocument(); expect(screen.getByText("Other Calendars")).toBeInTheDocument(); @@ -168,14 +172,20 @@ describe("CalendarSelection", () => { .closest(".MuiAccordionSummary-root"); const addButton = screen.getAllByTestId("AddIcon")[2]; - fireEvent.click(addButton); + await act(async () => { + fireEvent.click(addButton); + }); expect(sharedAccordionSummary).toHaveAttribute("aria-expanded", "true"); - fireEvent.click(addButton); + await act(async () => { + fireEvent.click(addButton); + }); expect(sharedAccordionSummary).toHaveAttribute("aria-expanded", "true"); }); - it("BUGFIX: remove dots in mini calendar when unselecting personnal calendar", () => { - renderWithProviders(, preloadedState); + it("BUGFIX: remove dots in mini calendar when unselecting personnal calendar", async () => { + await act(async () => + renderWithProviders(, preloadedState) + ); const checkbox = screen.getByLabelText("Calendar personnal"); // checkbox checked : events shown @@ -186,8 +196,9 @@ describe("CalendarSelection", () => { ).toHaveClass("event-dot"); // checkbox unchecked : events hidden - fireEvent.click(checkbox); - + await act(async () => { + fireEvent.click(checkbox); + }); expect( screen.getByTestId( `date-${start.getFullYear()}-${start.getMonth()}-${start.getDate()}` @@ -195,18 +206,24 @@ describe("CalendarSelection", () => { ).not.toHaveClass("event-dot"); // checkbox rechecked : events shown - fireEvent.click(checkbox); + await act(async () => { + fireEvent.click(checkbox); + }); expect( screen.getByTestId( `date-${start.getFullYear()}-${start.getMonth()}-${start.getDate()}` ) ).toHaveClass("event-dot"); }); - it("BUGFIX: remove dots in mini calendar when unselecting delegated calendar", () => { - renderWithProviders(, preloadedState); + it("BUGFIX: remove dots in mini calendar when unselecting delegated calendar", async () => { + await act(async () => + renderWithProviders(, preloadedState) + ); // hide personnal event first - fireEvent.click(screen.getByLabelText("Calendar personnal")); + await act(async () => { + fireEvent.click(screen.getByLabelText("Calendar personnal")); + }); const checkbox = screen.getByLabelText("Calendar delegated"); expect( @@ -216,18 +233,24 @@ describe("CalendarSelection", () => { ).not.toHaveClass("event-dot"); // checkbox checked : events shown - fireEvent.click(checkbox); + await act(async () => { + fireEvent.click(checkbox); + }); expect( screen.getByTestId( `date-${start.getFullYear()}-${start.getMonth()}-${start.getDate()}` ) ).toHaveClass("event-dot"); }); - it("BUGFIX: remove dots in mini calendar when unselecting shared calendar", () => { - renderWithProviders(, preloadedState); + it("BUGFIX: remove dots in mini calendar when unselecting shared calendar", async () => { + await act(async () => + renderWithProviders(, preloadedState) + ); // hide personnal event first - fireEvent.click(screen.getByLabelText("Calendar personnal")); + await act(async () => { + fireEvent.click(screen.getByLabelText("Calendar personnal")); + }); const checkbox = screen.getByLabelText("Calendar shared"); // checkbox unchecked : events hidden @@ -238,7 +261,9 @@ describe("CalendarSelection", () => { ).not.toHaveClass("event-dot"); // checkbox checked : events shown - fireEvent.click(checkbox); + await act(async () => { + fireEvent.click(checkbox); + }); expect( screen.getByTestId( `date-${start.getFullYear()}-${start.getMonth()}-${start.getDate()}` @@ -292,13 +317,18 @@ describe("calendar Availability search", () => { }, ]); - renderWithProviders(, preloadedState); + await act(async () => + renderWithProviders(, preloadedState) + ); const input = screen.getByPlaceholderText(/start typing a name or email/i); - userEvent.type(input, "New"); - + act(() => { + userEvent.type(input, "New"); + }); const option = await screen.findByText("New User"); - fireEvent.click(option); + await act(async () => { + fireEvent.click(option); + }); expect(spy).toHaveBeenCalled(); }); @@ -317,14 +347,17 @@ describe("calendar Availability search", () => { .mockImplementation((payload) => { return () => Promise.resolve(payload) as any; }); - renderWithProviders(, preloadedState); + await act(async () => + renderWithProviders(, preloadedState) + ); 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"); - fireEvent.click(option); - + await act(async () => { + fireEvent.click(option); + }); expect(spy).not.toHaveBeenCalledWith(); }); @@ -343,18 +376,22 @@ describe("calendar Availability search", () => { }, ]); - renderWithProviders(, preloadedState); + await act(async () => + renderWithProviders(, preloadedState) + ); 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"); - fireEvent.click(option); + await act(async () => { + fireEvent.click(option); + }); expect(spy).toHaveBeenCalled(); - - fireEvent.click(screen.getByRole("button", { name: /create event/i })); - + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /create event/i })); + }); await waitFor(() => { expect(screen.getAllByText(/Create Event/i)).toHaveLength(2); expect(screen.getAllByText(/New User/i)).toHaveLength(2); @@ -376,17 +413,21 @@ describe("calendar Availability search", () => { }, ]); - renderWithProviders(, preloadedState); + await act(async () => + renderWithProviders(, preloadedState) + ); 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"); - fireEvent.click(option); - + await act(async () => { + fireEvent.click(option); + }); expect(spy).toHaveBeenCalled(); - - fireEvent.keyDown(input, { key: "Enter" }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter" }); + }); await waitFor(() => { expect(screen.getAllByText(/Create Event/i)).toHaveLength(2); @@ -394,14 +435,16 @@ describe("calendar Availability search", () => { }); }); - it("BUGFIX: can untoggle all personnal calendars", () => { - renderWithProviders(, { - user: preloadedState.user, - calendars: { - list: { "user1/cal1": preloadedState.calendars.list["user1/cal1"] }, - pending: false, - }, - }); + it("BUGFIX: can untoggle all personnal calendars", async () => { + await act(async () => + renderWithProviders(, { + user: preloadedState.user, + calendars: { + list: { "user1/cal1": preloadedState.calendars.list["user1/cal1"] }, + pending: false, + }, + }) + ); const checkbox = screen.getByLabelText("Calendar personnal"); expect(checkbox).toBeChecked(); @@ -412,13 +455,15 @@ describe("calendar Availability search", () => { }); it("BUGFIX: monthview doesn't show days numbers in banner", async () => { - renderWithProviders(, { - user: preloadedState.user, - calendars: { - list: { "user1/cal1": preloadedState.calendars.list["user1/cal1"] }, - pending: false, - }, - }); + await act(async () => + renderWithProviders(, { + user: preloadedState.user, + calendars: { + list: { "user1/cal1": preloadedState.calendars.list["user1/cal1"] }, + pending: false, + }, + }) + ); const calendarRef = (window as any).__calendarRef; @@ -427,8 +472,9 @@ describe("calendar Availability search", () => { }); const calendarApi = calendarRef.current; - - calendarApi.changeView("dayGridMonth"); + await act(async () => { + calendarApi.changeView("dayGridMonth"); + }); await waitFor(() => { expect(screen.queryAllByRole("columnheader").length).toBe(14); }); @@ -452,7 +498,9 @@ describe("calendar Availability search", () => { return () => Promise.resolve(payload) as any; }); jest.useFakeTimers().setSystemTime(new Date("2025-01-01")); - renderWithProviders(, preloadedState); + await act(async () => + renderWithProviders(, preloadedState) + ); await waitFor(() => { expect(spy).toHaveBeenCalled(); @@ -461,9 +509,10 @@ describe("calendar Availability search", () => { const calendarRef = (window as any).__calendarRef; const calendarApi = calendarRef.current; const view = calendarApi?.view; - calendarApi.changeView("dayGridMonth"); - fireEvent.click(screen.getByTestId("ChevronRightIcon")); - + await act(async () => { + calendarApi.changeView("dayGridMonth"); + fireEvent.click(screen.getByTestId("ChevronRightIcon")); + }); expect(spy).toHaveBeenCalledTimes(4); const callArgs = spy.mock.calls[3][0]; expect(callArgs.calId).toBe("user1/cal1"); diff --git a/__test__/components/MiniCalendarColor.test.tsx b/__test__/components/MiniCalendarColor.test.tsx index 30bae71..34ed2f1 100644 --- a/__test__/components/MiniCalendarColor.test.tsx +++ b/__test__/components/MiniCalendarColor.test.tsx @@ -1,10 +1,9 @@ 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 CalendarApp from "../../src/components/Calendar/Calendar"; import * as appHooks from "../../src/app/hooks"; import { ThunkDispatch } from "@reduxjs/toolkit"; -import preview from "jest-preview"; describe("MiniCalendar", () => { const day = new Date(); diff --git a/__test__/components/PeopleSearch.test.tsx b/__test__/components/PeopleSearch.test.tsx index bfaae91..ae3bebd 100644 --- a/__test__/components/PeopleSearch.test.tsx +++ b/__test__/components/PeopleSearch.test.tsx @@ -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 { User, @@ -51,7 +51,9 @@ describe("PeopleSearch", () => { const input = screen.getByRole("combobox"); await userEvent.type(input, "Test"); - jest.advanceTimersByTime(300); + await act(async () => { + jest.advanceTimersByTime(300); + }); await waitFor(() => { expect(mockedSearchUsers).toHaveBeenCalledWith("Test", ["user"]); @@ -64,7 +66,9 @@ describe("PeopleSearch", () => { const input = screen.getByRole("combobox"); await userEvent.type(input, "Test"); - jest.advanceTimersByTime(300); + await act(async () => { + jest.advanceTimersByTime(300); + }); const option = await screen.findByText("Test User"); await userEvent.click(option); @@ -79,7 +83,9 @@ describe("PeopleSearch", () => { setup([baseUser]); const input = screen.getByRole("combobox"); await userEvent.type(input, "Test"); - jest.advanceTimersByTime(300); + await act(async () => { + jest.advanceTimersByTime(300); + }); await waitFor(() => { expect(screen.queryByText("test@example.com")).not.toBeInTheDocument(); diff --git a/__test__/features/Calendars/TimezoneInGrid.test.tsx b/__test__/features/Calendars/TimezoneInGrid.test.tsx index 3947444..5fc1e9d 100644 --- a/__test__/features/Calendars/TimezoneInGrid.test.tsx +++ b/__test__/features/Calendars/TimezoneInGrid.test.tsx @@ -37,11 +37,6 @@ describe("Calendar - Timezone Integration", () => { timeZone: "America/New_York", pending: false, }, - events: { - selectedEvent: null, - isEditMode: false, - editModeDialogOpen: false, - }, }; beforeEach(() => { diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index ff1aa09..8ab6129 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -104,6 +104,26 @@ describe("Event Preview Display", () => { }; 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( { 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(); - renderWithProviders(, { user: { loading: false } }); + renderWithProviders(, { user: { error: true } }); expect(dispatch).toHaveBeenCalledWith(push("/error")); }); }); diff --git a/src/App.tsx b/src/App.tsx index 955f7fe..482fe61 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( }> @@ -22,6 +32,8 @@ function App() { } /> + + ); diff --git a/src/components/Attendees/AttendeeSearch.tsx b/src/components/Attendees/AttendeeSearch.tsx index 2616053..91fa0ba 100644 --- a/src/components/Attendees/AttendeeSearch.tsx +++ b/src/components/Attendees/AttendeeSearch.tsx @@ -17,7 +17,7 @@ export default function UserSearch({ displayName: a.cn ?? "", avatarUrl: "", openpaasId: "", - })) + })) ?? [] ); useEffect(() => { setSelectedUsers( diff --git a/src/components/Calendar/CalendarLayout.tsx b/src/components/Calendar/CalendarLayout.tsx index 82988a3..a3b0c05 100644 --- a/src/components/Calendar/CalendarLayout.tsx +++ b/src/components/Calendar/CalendarLayout.tsx @@ -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(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(new Date()); @@ -62,6 +64,7 @@ export default function CalendarLayout() { onDateChange={handleDateChange} onViewChange={handleViewChange} /> + ); } diff --git a/src/components/Error/Error.tsx b/src/components/Error/Error.tsx index a1034e5..8f0c35a 100644 --- a/src/components/Error/Error.tsx +++ b/src/components/Error/Error.tsx @@ -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

Error

; + 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 ( + + + + + + + + + + Something went wrong + + + + {errorMessage} + + + + + + + + ); } diff --git a/src/components/Error/ErrorSnackbar.tsx b/src/components/Error/ErrorSnackbar.tsx index fe111c1..11adcc3 100644 --- a/src/components/Error/ErrorSnackbar.tsx +++ b/src/components/Error/ErrorSnackbar.tsx @@ -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 ( + + + OK + + } + > + {error} + + + ); } -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 diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 797eb72..35ebb32 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -29,192 +29,283 @@ import { import { User } from "../../components/Attendees/PeopleSearch"; import { getCalendarVisibility } from "../../components/Calendar/utils/calendarUtils"; 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< - Record // Return type ->("calendars/getCalendars", async () => { - const importedCalendars: Record = {}; - const user = (await getOpenPaasUser()) as Record; - const calendars = (await getCalendars(user.id)) as Record; - const rawCalendars = calendars._embedded["dav:calendar"]; + { importedCalendars: Record; errors: string }, // Return type + void, // Arg type + { rejectValue: RejectedError } // ThunkAPI config +>("calendars/getCalendars", async (_, { rejectWithValue }) => { + try { + const importedCalendars: Record = {}; + const user = (await getOpenPaasUser()) as Record; + const calendars = (await getCalendars(user.id)) as Record; + const rawCalendars = calendars._embedded["dav:calendar"]; + const errors = []; + for (const cal of rawCalendars) { + const description = cal["caldav:description"]; + let delegated = false; + let source = cal["calendarserver:source"] + ? cal["calendarserver:source"]._links.self.href + : cal._links.self.href; + const link = cal._links.self.href; + if (cal["calendarserver:delegatedsource"]) { + source = cal["calendarserver:delegatedsource"]; + delegated = true; + } + const id = source.replace("/calendars/", "").replace(".json", ""); + const ownerId = id.split("/")[0]; + const visibility = getCalendarVisibility(cal["acl"]); - for (const cal of rawCalendars) { - const description = cal["caldav:description"]; - let delegated = false; - let source = cal["calendarserver:source"] - ? cal["calendarserver:source"]._links.self.href - : cal._links.self.href; - const link = cal._links.self.href; - if (cal["calendarserver:delegatedsource"]) { - source = cal["calendarserver:delegatedsource"]; - delegated = true; + // 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 = + ownerId !== user.id && cal["dav:name"] === "#default" + ? `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ + ownerData.lastname + }` + "'s calendar" + : cal["dav:name"]; + + const color = { + light: cal["apple:color"] ?? "#006BD8", + dark: cal["X-TWAKE-Dark-theme-color"] ?? "#FFF", + }; + importedCalendars[id] = { + id, + name, + link, + owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ + ownerData.lastname + }`, + ownerEmails: ownerData.emails, + description, + delegated, + color, + visibility, + events: {}, + }; } - const id = source.replace("/calendars/", "").replace(".json", ""); - const ownerId = id.split("/")[0]; - const visibility = getCalendarVisibility(cal["acl"]); - const ownerData: any = await getUserDetails(ownerId); - const name = - ownerId !== user.id && cal["dav:name"] === "#default" - ? `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ - ownerData.lastname - }` + "'s calendar" - : cal["dav:name"]; - const color = { - light: cal["apple:color"] ?? "#006BD8", - dark: cal["X-TWAKE-Dark-theme-color"] ?? "#FFF", - }; - importedCalendars[id] = { - id, - name, - link, - owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ - ownerData.lastname - }`, - ownerEmails: ownerData.emails, - description, - delegated, - color, - visibility, - events: {}, - }; + return { importedCalendars, errors: errors.join("\n") }; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, + }); } - - return importedCalendars; }); export const getTempCalendarsListAsync = createAsyncThunk< Record, - User ->("calendars/getTempCalendars", async (tempUser) => { - const importedCalendars: Record = {}; + User, + { rejectValue: RejectedError } +>("calendars/getTempCalendars", async (tempUser, { rejectWithValue }) => { + try { + const importedCalendars: Record = {}; - const calendars = (await getCalendars( - tempUser.openpaasId ?? "", - "sharedPublic=true&WithRights=true" - )) as Record; - const rawCalendars = calendars._embedded["dav:calendar"]; + const calendars = (await getCalendars( + tempUser.openpaasId ?? "", + "sharedPublic=true&WithRights=true" + )) as Record; + const rawCalendars = calendars._embedded["dav:calendar"]; - for (const cal of rawCalendars) { - const name = cal["dav:name"]; - const description = cal["caldav:description"]; - const delegated = cal["calendarserver:delegatedsource"] ? true : false; - const source = cal["calendarserver:source"] - ? cal["calendarserver:source"]._links.self.href - : cal._links.self.href; - const link = cal._links.self.href; + for (const cal of rawCalendars) { + const name = cal["dav:name"]; + const description = cal["caldav:description"]; + const delegated = cal["calendarserver:delegatedsource"] ? true : false; + const source = cal["calendarserver:source"] + ? cal["calendarserver:source"]._links.self.href + : cal._links.self.href; + const link = cal._links.self.href; - const id = source.replace("/calendars/", "").replace(".json", ""); - const visibility = getCalendarVisibility(cal["acl"]); - const ownerData: any = await getUserDetails(id.split("/")[0]); + const id = source.replace("/calendars/", "").replace(".json", ""); + const visibility = getCalendarVisibility(cal["acl"]); + const ownerData: any = await getUserDetails(id.split("/")[0]); - importedCalendars[id] = { - id, - name, - link, - owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ownerData.lastname}`, - ownerEmails: ownerData.emails, - description, - delegated, - color: { - light: tempUser.color?.light ?? "#a8a8a8ff", - dark: tempUser.color?.dark ?? "#a8a8a8ff", - }, - visibility, - events: {}, - }; + importedCalendars[id] = { + id, + name, + link, + owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ownerData.lastname}`, + ownerEmails: ownerData.emails, + description, + delegated, + color: { + light: tempUser.color?.light ?? "#a8a8a8ff", + dark: tempUser.color?.dark ?? "#a8a8a8ff", + }, + visibility, + events: {}, + }; + } + + return importedCalendars; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, + }); } - - return importedCalendars; }); export const getCalendarDetailAsync = createAsyncThunk< - { calId: string; events: CalendarEvent[]; calType?: string }, // Return type - { calId: string; match: { start: string; end: string }; calType?: string } // Arg type ->("calendars/getCalendarDetails", async ({ calId, match, calType }) => { - const calendar = (await getCalendar(calId, match)) as Record; - const color = calendar["apple:color"]; - const events: CalendarEvent[] = calendar._embedded["dav:item"].flatMap( - (eventdata: any) => { - const vevents = eventdata.data[2] as any[][]; // array of ['vevent', RawEntry[], []] - const valarm = eventdata.data[2][0][2][0]; - const eventURL = eventdata._links.self.href; - return vevents.map((vevent: any[]) => { - return parseCalendarEvent(vevent[1], color, calId, eventURL, valarm); + { calId: string; events: CalendarEvent[]; calType?: string }, + { calId: string; match: { start: string; end: string }; calType?: string }, + { rejectValue: RejectedError } +>( + "calendars/getCalendarDetails", + async ({ calId, match, calType }, { rejectWithValue }) => { + try { + const calendar = (await getCalendar(calId, match)) as Record; + const color = calendar["apple:color"]; + const events: CalendarEvent[] = calendar._embedded["dav:item"].flatMap( + (eventdata: any) => { + const vevents = eventdata.data[2] as any[][]; + const valarm = eventdata.data[2][0][2][0]; + const eventURL = eventdata._links.self.href; + return vevents.map((vevent: any[]) => { + return parseCalendarEvent( + vevent[1], + color, + calId, + eventURL, + valarm + ); + }); + } + ); + + return { calId, events, calType }; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, }); } - ); - - return { calId, events, calType }; -}); + } +); export const putEventAsync = createAsyncThunk< - { calId: string; events: CalendarEvent[]; calType?: "temp" }, // Return type - { cal: Calendars; newEvent: CalendarEvent; calType?: "temp" } // Arg type ->("calendars/putEvent", async ({ cal, newEvent, calType }) => { - await putEvent(newEvent, cal.ownerEmails ? cal.ownerEmails[0] : undefined); - const eventDate = new Date(newEvent.start); + { calId: string; events: CalendarEvent[]; calType?: "temp" }, + { cal: Calendars; newEvent: CalendarEvent; calType?: "temp" }, + { rejectValue: RejectedError } +>( + "calendars/putEvent", + async ({ cal, newEvent, calType }, { rejectWithValue }) => { + try { + await putEvent( + newEvent, + cal.ownerEmails ? cal.ownerEmails[0] : undefined + ); + 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, { - start: formatDateToYYYYMMDDTHHMMSS(weekStart), - end: formatDateToYYYYMMDDTHHMMSS(weekEnd), - })) as Record; - const events: CalendarEvent[] = calEvents._embedded["dav:item"].flatMap( - (eventdata: any) => { - const vevents = eventdata.data[2] as any[][]; - const eventURL = eventdata._links.self.href; - const valarm = eventdata.data[2][0][2][0]; - return vevents.map((vevent: any[]) => { - return parseCalendarEvent( - vevent[1], - cal.color ?? {}, - cal.id, - eventURL, - valarm - ); + const calEvents = (await getCalendar(cal.id, { + start: formatDateToYYYYMMDDTHHMMSS(weekStart), + end: formatDateToYYYYMMDDTHHMMSS(weekEnd), + })) as Record; + const events: CalendarEvent[] = calEvents._embedded["dav:item"].flatMap( + (eventdata: any) => { + const vevents = eventdata.data[2] as any[][]; + const eventURL = eventdata._links.self.href; + const valarm = eventdata.data[2][0][2][0]; + return vevents.map((vevent: any[]) => { + return parseCalendarEvent( + vevent[1], + cal.color ?? {}, + cal.id, + eventURL, + valarm + ); + }); + } + ); + + return { + calId: cal.id, + events, + calType, + }; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, }); } - ); - - return { - calId: cal.id, - events, - calType, - }; -}); + } +); export const getEventAsync = createAsyncThunk< - { calId: string; event: CalendarEvent }, // Return type - CalendarEvent // Arg type ->("calendars/getEvent", async (event) => { - const response: CalendarEvent = await getEvent(event); - return { - calId: event.calId, - event: response, - }; + { calId: string; event: CalendarEvent }, + CalendarEvent, + { rejectValue: RejectedError } +>("calendars/getEvent", async (event, { rejectWithValue }) => { + try { + const response: CalendarEvent = await getEvent(event); + return { + calId: event.calId, + event: response, + }; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, + }); + } }); + export const patchCalendarAsync = createAsyncThunk< { calId: string; calLink: string; patch: { name: string; desc: string; color: Record }; - }, // Return type + }, { calId: string; calLink: string; patch: { name: string; desc: string; color: Record }; - } // Arg type ->("calendars/patchCalendar", async ({ calId, calLink, patch }) => { - await proppatchCalendar(calLink, patch); - return { - calId, - calLink, - patch, - }; -}); + }, + { rejectValue: RejectedError } +>( + "calendars/patchCalendar", + async ({ calId, calLink, patch }, { rejectWithValue }) => { + try { + await proppatchCalendar(calLink, patch); + return { + calId, + calLink, + patch, + }; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, + }); + } + } +); export const removeCalendarAsync = createAsyncThunk< { @@ -223,95 +314,173 @@ export const removeCalendarAsync = createAsyncThunk< { calId: string; calLink: string; - } ->("calendars/removeCalendar", async ({ calId, calLink }) => { - await removeCalendar(calLink); - return { - calId, - calLink, - }; -}); - -export const moveEventAsync = createAsyncThunk< - { calId: string; events: CalendarEvent[] }, // Return type - { cal: Calendars; newEvent: CalendarEvent; newURL: string } // Arg type ->("calendars/moveEvent", async ({ cal, 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 { start: weekStart, end: weekEnd } = computeWeekRange(eventDate); - - const calEvents = (await getCalendar(cal.id, { - start: formatDateToYYYYMMDDTHHMMSS(weekStart), - end: formatDateToYYYYMMDDTHHMMSS(weekEnd), - })) as Record; - const events: CalendarEvent[] = calEvents._embedded["dav:item"].flatMap( - (eventdata: any) => { - const vevents = eventdata.data[2] as any[][]; - const eventURL = eventdata._links.self.href; - return vevents.map((vevent: any[]) => { - return parseCalendarEvent(vevent[1], cal.color ?? {}, cal.id, eventURL); + }, + { rejectValue: RejectedError } +>( + "calendars/removeCalendar", + async ({ calId, calLink }, { rejectWithValue }) => { + try { + await removeCalendar(calLink); + return { + calId, + calLink, + }; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, }); } - ); + } +); - return { - calId: cal.id, - events, - }; -}); +export const moveEventAsync = createAsyncThunk< + { calId: string; events: CalendarEvent[] }, + { cal: Calendars; newEvent: CalendarEvent; newURL: string }, + { rejectValue: RejectedError } +>( + "calendars/moveEvent", + async ({ cal, newEvent, newURL }, { rejectWithValue }) => { + try { + await moveEvent(newEvent, newURL); + + const eventDate = new Date(newEvent.start); + const { start: weekStart, end: weekEnd } = computeWeekRange(eventDate); + + const calEvents = (await getCalendar(cal.id, { + start: formatDateToYYYYMMDDTHHMMSS(weekStart), + end: formatDateToYYYYMMDDTHHMMSS(weekEnd), + })) as Record; + const events: CalendarEvent[] = calEvents._embedded["dav:item"].flatMap( + (eventdata: any) => { + const vevents = eventdata.data[2] as any[][]; + const eventURL = eventdata._links.self.href; + return vevents.map((vevent: any[]) => { + return parseCalendarEvent( + vevent[1], + cal.color ?? {}, + cal.id, + eventURL + ); + }); + } + ); + + return { + calId: cal.id, + events, + }; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, + }); + } + } +); export const patchACLCalendarAsync = createAsyncThunk< { calId: string; calLink: string; request: string; - }, // Return type + }, { calId: string; calLink: string; request: string; - } // Arg type ->("calendars/requestACLCalendar", async ({ calId, calLink, request }) => { - const response = await updateAclCalendar(calLink, request); - return { - calId, - calLink, - request, - }; -}); + }, + { rejectValue: RejectedError } +>( + "calendars/requestACLCalendar", + async ({ calId, calLink, request }, { rejectWithValue }) => { + try { + const response = await updateAclCalendar(calLink, request); + return { + calId, + calLink, + request, + }; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, + }); + } + } +); export const deleteEventAsync = createAsyncThunk< - { calId: string; eventId: string }, // Return type - { calId: string; eventId: string; eventURL: string } // Arg type ->("calendars/delEvent", async ({ calId, eventId, eventURL }) => { - await deleteEvent(eventURL); - return { calId, eventId }; -}); + { calId: string; eventId: string }, + { calId: string; eventId: string; eventURL: string }, + { rejectValue: RejectedError } +>( + "calendars/delEvent", + async ({ calId, eventId, eventURL }, { rejectWithValue }) => { + try { + await deleteEvent(eventURL); + return { calId, eventId }; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, + }); + } + } +); export const deleteEventInstanceAsync = createAsyncThunk< { calId: string; eventId: string }, - { cal: Calendars; event: CalendarEvent } ->("calendars/delEventInstance", async ({ cal, event }) => { - await deleteEventInstance(event, cal.ownerEmails?.[0]); - return { calId: cal.id, eventId: event.uid }; + { cal: Calendars; event: CalendarEvent }, + { rejectValue: RejectedError } +>("calendars/delEventInstance", async ({ cal, event }, { rejectWithValue }) => { + try { + await deleteEventInstance(event, cal.ownerEmails?.[0]); + return { calId: cal.id, eventId: event.uid }; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, + }); + } }); export const updateEventInstanceAsync = createAsyncThunk< { calId: string; event: CalendarEvent }, - { cal: Calendars; event: CalendarEvent } ->("calendars/updateEventInstance", async ({ cal, event }) => { - await putEventWithOverrides(event, cal.ownerEmails?.[0]); - return { calId: cal.id, event }; -}); + { cal: Calendars; event: CalendarEvent }, + { rejectValue: RejectedError } +>( + "calendars/updateEventInstance", + async ({ cal, event }, { rejectWithValue }) => { + try { + await putEventWithOverrides(event, cal.ownerEmails?.[0]); + return { calId: cal.id, event }; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, + }); + } + } +); export const updateSeriesAsync = createAsyncThunk< void, - { cal: Calendars; event: CalendarEvent; removeOverrides?: boolean } ->("calendars/updateSeries", async ({ cal, event, removeOverrides = true }) => { - await updateSeries(event, cal.ownerEmails?.[0], removeOverrides); -}); + { cal: Calendars; event: CalendarEvent; removeOverrides?: boolean }, + { rejectValue: RejectedError } +>( + "calendars/updateSeries", + async ({ cal, event, removeOverrides = true }, { rejectWithValue }) => { + try { + await updateSeries(event, cal.ownerEmails?.[0], removeOverrides); + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, + }); + } + } +); export const createCalendarAsync = createAsyncThunk< { @@ -322,30 +491,41 @@ export const createCalendarAsync = createAsyncThunk< desc: string; owner: string; ownerEmails: string[]; - }, // Return type + }, { userId: string; calId: string; color: Record; name: string; desc: string; - } // Arg type ->("calendars/createCalendar", async ({ userId, calId, color, name, desc }) => { - await postCalendar(userId, calId, color, name, desc); - const ownerData: any = await getUserDetails(userId.split("/")[0]); + }, + { rejectValue: RejectedError } +>( + "calendars/createCalendar", + async ({ userId, calId, color, name, desc }, { rejectWithValue }) => { + try { + await postCalendar(userId, calId, color, name, desc); + const ownerData: any = await getUserDetails(userId.split("/")[0]); - return { - userId, - calId, - color, - name, - desc, - owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ - ownerData.lastname - }`, - ownerEmails: ownerData.emails, - }; -}); + return { + userId, + calId, + color, + name, + desc, + owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ + ownerData.lastname + }`, + ownerEmails: ownerData.emails, + }; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, + }); + } + } +); export const addSharedCalendarAsync = createAsyncThunk< { @@ -356,49 +536,68 @@ export const addSharedCalendarAsync = createAsyncThunk< desc: string; owner: string; ownerEmails: string[]; - }, // Return type - { userId: string; calId: string; cal: Record } // Arg type ->("calendars/addSharedCalendar", async ({ userId, calId, cal }) => { - await addSharedCalendar(userId, calId, cal); - const ownerData: any = await getUserDetails( - cal.cal._links.self.href - .replace("/calendars/", "") - .replace(".json", "") - .split("/")[0] - ); + }, + { userId: string; calId: string; cal: Record }, + { rejectValue: RejectedError } +>( + "calendars/addSharedCalendar", + async ({ userId, calId, cal }, { rejectWithValue }) => { + try { + await addSharedCalendar(userId, calId, cal); + const ownerData: any = await getUserDetails( + cal.cal._links.self.href + .replace("/calendars/", "") + .replace(".json", "") + .split("/")[0] + ); - return { - calId: cal.cal._links.self.href - .replace("/calendars/", "") - .replace(".json", ""), - color: { - light: cal.cal["apple:color"], - dark: "#000", - }, - link: `/calendars/${userId}/${calId}.json`, - desc: cal.cal["caldav:description"], - name: - ownerData.id !== userId && cal.cal["dav:name"] === "#default" - ? `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ - ownerData.lastname - }` + "'s calendar" - : cal.cal["dav:name"], - owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ - ownerData.lastname - }`, - ownerEmails: ownerData.emails, - }; -}); + return { + calId: cal.cal._links.self.href + .replace("/calendars/", "") + .replace(".json", ""), + color: { + light: cal.cal["apple:color"], + dark: "#000", + }, + link: `/calendars/${userId}/${calId}.json`, + desc: cal.cal["caldav:description"], + name: + ownerData.id !== userId && cal.cal["dav:name"] === "#default" + ? `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ + ownerData.lastname + }` + "'s calendar" + : cal.cal["dav:name"], + owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ + ownerData.lastname + }`, + ownerEmails: ownerData.emails, + }; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, + }); + } + } +); export const importEventFromFileAsync = createAsyncThunk< void, { calLink: string; file: File; + }, + { rejectValue: RejectedError } +>("calendars/importEvent", async ({ calLink, file }, { rejectWithValue }) => { + try { + const id = ((await importFile(file)) as Record)._id; + const response = await importEventFromFile(id, calLink); + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, + }); } ->("calendars/importEvent", async ({ calLink, file }) => { - const id = ((await importFile(file)) as Record)._id; - const response = await importEventFromFile(id, calLink); }); const CalendarSlice = createSlice({ @@ -407,11 +606,13 @@ const CalendarSlice = createSlice({ list: {} as Record, templist: {} as Record, pending: false, + error: null as string | null, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, } as { list: Record; templist: Record; pending: boolean; + error: string | null; timeZone: string; }, reducers: { @@ -480,6 +681,9 @@ const CalendarSlice = createSlice({ if (!state.list[action.payload]) return; state.list[action.payload].lastCacheCleared = Date.now(); }, + clearError: (state) => { + state.error = null; + }, updateCalColor: ( state, action: PayloadAction<{ @@ -492,11 +696,21 @@ const CalendarSlice = createSlice({ }, extraReducers: (builder) => { builder + // Fulfilled cases .addCase( getCalendarsListAsync.fulfilled, - (state, action: PayloadAction>) => { + ( + state, + action: PayloadAction<{ + importedCalendars: Record; + errors: string; + }> + ) => { state.pending = false; - state.list = action.payload; + state.list = action.payload.importedCalendars; + state.error = action.payload.errors.length + ? action.payload.errors + : null; } ) .addCase( @@ -636,18 +850,22 @@ const CalendarSlice = createSlice({ action.payload.eventId ]; } + state.error = null; }) .addCase(deleteEventInstanceAsync.fulfilled, (state, action) => { state.pending = false; delete state.list[action.payload.calId].events[action.payload.eventId]; + state.error = null; }) .addCase(updateEventInstanceAsync.fulfilled, (state, action) => { state.pending = false; state.list[action.payload.calId].events[action.payload.event.uid] = action.payload.event; + state.error = null; }) .addCase(updateSeriesAsync.fulfilled, (state) => { state.pending = false; + state.error = null; }) .addCase(createCalendarAsync.fulfilled, (state, action) => { state.pending = false; @@ -661,6 +879,7 @@ const CalendarSlice = createSlice({ ownerEmails: action.payload.ownerEmails, events: {}, } as Calendars; + state.error = null; }) .addCase(patchCalendarAsync.fulfilled, (state, action) => { state.pending = false; @@ -687,6 +906,7 @@ const CalendarSlice = createSlice({ name: action.payload.patch.name, }; } + state.error = null; }) .addCase(addSharedCalendarAsync.fulfilled, (state, action) => { state.pending = false; @@ -700,16 +920,24 @@ const CalendarSlice = createSlice({ owner: action.payload.owner, ownerEmails: action.payload.ownerEmails, } as Calendars; + state.error = null; }) .addCase(removeCalendarAsync.fulfilled, (state, action) => { state.pending = false; delete state.list[action.payload.calId]; + state.error = null; }) .addCase(patchACLCalendarAsync.fulfilled, (state, action) => { state.pending = false; state.list[action.payload.calId].visibility = action.payload.request !== "" ? "public" : "private"; + state.error = null; }) + .addCase(importEventFromFileAsync.fulfilled, (state) => { + state.pending = false; + state.error = null; + }) + // Pending cases .addCase(getCalendarDetailAsync.pending, (state) => { state.pending = true; }) @@ -728,6 +956,15 @@ const CalendarSlice = createSlice({ .addCase(deleteEventAsync.pending, (state) => { 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) => { state.pending = true; }) @@ -745,6 +982,124 @@ const CalendarSlice = createSlice({ }) .addCase(patchACLCalendarAsync.pending, (state) => { 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, setTimeZone, clearFetchCache, + clearError, updateCalColor, } = CalendarSlice.actions; export default CalendarSlice.reducer; diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index c4030b8..03562b4 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -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( diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index ae1a9c8..9c889b8 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -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( 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({