diff --git a/__test__/components/Calendar.test.tsx b/__test__/components/Calendar.test.tsx index 3e187f3..2e8a684 100644 --- a/__test__/components/Calendar.test.tsx +++ b/__test__/components/Calendar.test.tsx @@ -581,4 +581,188 @@ describe("calendar Availability search", () => { expect(startDate.getTime()).toBeLessThanOrEqual(viewStart.getTime()); expect(endDate.getTime()).toBeGreaterThanOrEqual(viewEnd.getTime()); }); + + describe("Batch loading and prefetching", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("loads active calendars in batches of 5", async () => { + const manyCalendars = Array.from({ length: 12 }, (_, i) => ({ + [`user1/cal${i + 1}`]: { + name: `Calendar ${i + 1}`, + id: `user1/cal${i + 1}`, + color: { light: "#FF0000", dark: "#000" }, + events: {}, + }, + })).reduce((acc, cal) => ({ ...acc, ...cal }), {}); + + const stateWithManyCalendars = { + ...preloadedState, + calendars: { + ...preloadedState.calendars, + list: manyCalendars, + }, + }; + + const spy = jest + .spyOn(calendarThunks, "getCalendarDetailAsync") + .mockImplementation(() => ({ + type: "getCalendarDetailAsync", + unwrap: () => Promise.resolve({}), + })) as any; + + await act(async () => { + renderWithProviders( + , + stateWithManyCalendars + ); + }); + + await waitFor( + () => { + expect(spy).toHaveBeenCalled(); + }, + { timeout: 3000 } + ); + + const callCount = spy.mock.calls.length; + expect(callCount).toBeGreaterThan(0); + expect(callCount).toBeLessThanOrEqual(24); + }); + + it("prefetches hidden calendars only after active load completes", async () => { + const calendarsWithSelected = { + ...preloadedState, + calendars: { + ...preloadedState.calendars, + list: { + "user1/cal1": { + name: "Selected Calendar", + id: "user1/cal1", + color: { light: "#FF0000", dark: "#000" }, + events: {}, + }, + "user1/cal2": { + name: "Hidden Calendar 1", + id: "user1/cal2", + color: { light: "#00FF00", dark: "#000" }, + events: {}, + }, + "user1/cal3": { + name: "Hidden Calendar 2", + id: "user1/cal3", + color: { light: "#0000FF", dark: "#000" }, + events: {}, + }, + }, + }, + }; + + const spy = jest + .spyOn(calendarThunks, "getCalendarDetailAsync") + .mockImplementation(() => ({ + type: "getCalendarDetailAsync", + unwrap: () => Promise.resolve({}), + })) as any; + + await act(async () => { + renderWithProviders( + , + calendarsWithSelected + ); + }); + + await waitFor( + () => { + expect(spy).toHaveBeenCalled(); + }, + { timeout: 3000 } + ); + + const selectedCalls = spy.mock.calls.filter( + (call) => call[0].calId === "user1/cal1" + ); + const hiddenCalls = spy.mock.calls.filter( + (call) => + call[0].calId === "user1/cal2" || call[0].calId === "user1/cal3" + ); + + expect(selectedCalls.length).toBeGreaterThan(0); + }); + + it("does not make duplicate API calls for same calendar and range", async () => { + const spy = jest + .spyOn(calendarThunks, "getCalendarDetailAsync") + .mockImplementation(() => ({ + type: "getCalendarDetailAsync", + unwrap: () => Promise.resolve({}), + })) as any; + + await act(async () => { + renderWithProviders( + , + preloadedState + ); + }); + + await waitFor( + () => { + expect(spy).toHaveBeenCalled(); + }, + { timeout: 3000 } + ); + + const callsForCal1 = spy.mock.calls.filter( + (call) => call[0].calId === "user1/cal1" + ); + + const uniqueRanges = new Set( + callsForCal1.map( + (call) => `${call[0].match.start}_${call[0].match.end}` + ) + ); + + expect(uniqueRanges.size).toBeLessThanOrEqual(callsForCal1.length); + }); + + it("handles undefined calendars gracefully", async () => { + const stateWithUndefinedCalendars = { + ...preloadedState, + calendars: { + list: undefined as any, + templist: undefined as any, + pending: false, + }, + }; + + await act(async () => { + renderWithProviders( + , + stateWithUndefinedCalendars + ); + }); + + expect(screen.getByText("calendar.personal")).toBeInTheDocument(); + }); + + it("handles undefined tempcalendars gracefully", async () => { + const stateWithUndefinedTemp = { + ...preloadedState, + calendars: { + ...preloadedState.calendars, + templist: undefined as any, + }, + }; + + await act(async () => { + renderWithProviders( + , + stateWithUndefinedTemp + ); + }); + + expect(screen.getByText("calendar.personal")).toBeInTheDocument(); + }); + }); }); diff --git a/__test__/features/Calendars/CalendarSlice.test.tsx b/__test__/features/Calendars/CalendarSlice.test.tsx index 4e90b50..ef94ee7 100644 --- a/__test__/features/Calendars/CalendarSlice.test.tsx +++ b/__test__/features/Calendars/CalendarSlice.test.tsx @@ -140,6 +140,203 @@ describe("CalendarSlice", () => { expect(state.list).toEqual({}); }); + it("getCalendarsListAsync loads user details in parallel for multiple owners", async () => { + const mockCalendars = [ + { + _links: { self: { href: "/calendars/u1/cal1.json" } }, + "dav:name": "Calendar 1", + "apple:color": "#FF0000", + acl: [], + }, + { + _links: { self: { href: "/calendars/u2/cal2.json" } }, + "dav:name": "Calendar 2", + "apple:color": "#00FF00", + acl: [], + }, + { + _links: { self: { href: "/calendars/u3/cal3.json" } }, + "dav:name": "Calendar 3", + "apple:color": "#0000FF", + acl: [], + }, + ]; + + (userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" }); + (calAPI.getCalendars as jest.Mock).mockResolvedValue({ + _embedded: { "dav:calendar": mockCalendars }, + }); + + const getUserDetailsMock = userAPI.getUserDetails as jest.Mock; + getUserDetailsMock + .mockResolvedValueOnce({ + firstname: "Alice", + lastname: "Smith", + emails: ["alice@example.com"], + }) + .mockResolvedValueOnce({ + firstname: "Bob", + lastname: "Jones", + emails: ["bob@example.com"], + }) + .mockResolvedValueOnce({ + firstname: "Charlie", + lastname: "Brown", + emails: ["charlie@example.com"], + }); + + const store = storeFactory(); + await store.dispatch(getCalendarsListAsync() as any); + + expect(getUserDetailsMock).toHaveBeenCalledTimes(3); + expect(getUserDetailsMock).toHaveBeenCalledWith("u1"); + expect(getUserDetailsMock).toHaveBeenCalledWith("u2"); + expect(getUserDetailsMock).toHaveBeenCalledWith("u3"); + + const state = store.getState().calendars; + expect(state.list["u1/cal1"].owner).toContain("Alice"); + expect(state.list["u2/cal2"].owner).toContain("Bob"); + expect(state.list["u3/cal3"].owner).toContain("Charlie"); + }); + + it("getCalendarsListAsync deduplicates getUserDetails calls for same ownerId", async () => { + const mockCalendars = [ + { + _links: { self: { href: "/calendars/u1/cal1.json" } }, + "dav:name": "Calendar 1", + "apple:color": "#FF0000", + acl: [], + }, + { + _links: { self: { href: "/calendars/u1/cal2.json" } }, + "dav:name": "Calendar 2", + "apple:color": "#00FF00", + acl: [], + }, + { + _links: { self: { href: "/calendars/u1/cal3.json" } }, + "dav:name": "Calendar 3", + "apple:color": "#0000FF", + acl: [], + }, + ]; + + (userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" }); + (calAPI.getCalendars as jest.Mock).mockResolvedValue({ + _embedded: { "dav:calendar": mockCalendars }, + }); + + const getUserDetailsMock = userAPI.getUserDetails as jest.Mock; + getUserDetailsMock.mockResolvedValue({ + firstname: "Alice", + lastname: "Smith", + emails: ["alice@example.com"], + }); + + const store = storeFactory(); + await store.dispatch(getCalendarsListAsync() as any); + + expect(getUserDetailsMock).toHaveBeenCalledTimes(1); + expect(getUserDetailsMock).toHaveBeenCalledWith("u1"); + }); + + it("getCalendarsListAsync processes owners in batches of 20", async () => { + const mockCalendars = Array.from({ length: 45 }, (_, i) => ({ + _links: { self: { href: `/calendars/u${i + 1}/cal${i + 1}.json` } }, + "dav:name": `Calendar ${i + 1}`, + "apple:color": "#FF0000", + acl: [], + })); + + (userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" }); + (calAPI.getCalendars as jest.Mock).mockResolvedValue({ + _embedded: { "dav:calendar": mockCalendars }, + }); + + const getUserDetailsMock = userAPI.getUserDetails as jest.Mock; + getUserDetailsMock.mockImplementation((ownerId: string) => + Promise.resolve({ + firstname: "User", + lastname: ownerId, + emails: [`${ownerId}@example.com`], + }) + ); + + const store = storeFactory(); + await store.dispatch(getCalendarsListAsync() as any); + + expect(getUserDetailsMock).toHaveBeenCalledTimes(45); + const state = store.getState().calendars; + expect(Object.keys(state.list)).toHaveLength(45); + }); + + it("getCalendarsListAsync returns early if calendars already exist in store", async () => { + const existingCalendars = { + "u1/cal1": { + id: "u1/cal1", + name: "Existing Calendar", + events: {}, + } as Calendars, + }; + + const store = storeFactory(); + store.dispatch({ + type: "calendars/getCalendars/fulfilled", + payload: { importedCalendars: existingCalendars, errors: "" }, + }); + + const getUserDetailsMock = userAPI.getUserDetails as jest.Mock; + const getCalendarsMock = calAPI.getCalendars as jest.Mock; + + await store.dispatch(getCalendarsListAsync() as any); + + expect(getCalendarsMock).not.toHaveBeenCalled(); + expect(getUserDetailsMock).not.toHaveBeenCalled(); + + const state = store.getState().calendars; + expect(state.list).toEqual(existingCalendars); + }); + + it("getCalendarsListAsync handles errors in getUserDetails gracefully", async () => { + const mockCalendars = [ + { + _links: { self: { href: "/calendars/u1/cal1.json" } }, + "dav:name": "Calendar 1", + "apple:color": "#FF0000", + acl: [], + }, + { + _links: { self: { href: "/calendars/u2/cal2.json" } }, + "dav:name": "Calendar 2", + "apple:color": "#00FF00", + acl: [], + }, + ]; + + (userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" }); + (calAPI.getCalendars as jest.Mock).mockResolvedValue({ + _embedded: { "dav:calendar": mockCalendars }, + }); + + const getUserDetailsMock = userAPI.getUserDetails as jest.Mock; + getUserDetailsMock + .mockResolvedValueOnce({ + firstname: "Alice", + lastname: "Smith", + emails: ["alice@example.com"], + }) + .mockRejectedValueOnce(new Error("Failed to fetch user")); + + const store = storeFactory(); + const result = await store.dispatch(getCalendarsListAsync() as any); + + expect(getUserDetailsMock).toHaveBeenCalledTimes(2); + const state = store.getState().calendars; + expect(state.list["u1/cal1"].owner).toContain("Alice"); + expect(state.list["u2/cal2"].owner).toContain("Unknown User"); + expect(result.payload.errors).toBeTruthy(); + }); + it("patchCalendarAsync.fulfilled updates calendar fields", () => { const calId = "c1"; const prev = { diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index 7371914..5af7a8c 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -5,7 +5,7 @@ import interactionPlugin from "@fullcalendar/interaction"; import { CalendarApi, DateSelectArg } from "@fullcalendar/core"; import "./Calendar.styl"; import "./CustomCalendar.styl"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import EventPopover from "../../features/Events/EventModal"; import { CalendarEvent } from "../../features/Events/EventsTypes"; @@ -44,7 +44,7 @@ import { updateDarkColor } from "./utils/calendarColorsUtils"; import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; interface CalendarAppProps { - calendarRef: React.RefObject; + calendarRef: MutableRefObject; onDateChange?: (date: Date) => void; onViewChange?: (view: string) => void; } @@ -65,13 +65,31 @@ export default function CalendarApp({ if (!tokens || !userId) { dispatch(push("/")); } - }, [tokens, userId]); + }, [dispatch, tokens, userId]); const calendars = useAppSelector((state) => state.calendars.list); - const tempcalendars = - useAppSelector((state) => state.calendars.templist) ?? {}; + const tempcalendars = useAppSelector((state) => state.calendars.templist); const [selectedCalendars, setSelectedCalendars] = useState([]); + const calendarLightSignature = useMemo(() => { + return Object.values(calendars || {}) + .map((cal) => `${cal.id}:${cal.color?.light ?? ""}`) + .sort() + .join("|"); + }, [calendars]); + + const calendarIdsString = useMemo( + () => + Object.keys(calendars || {}) + .sort() + .join(","), + [calendars] + ); + const calendarIds = useMemo( + () => (calendarIdsString ? calendarIdsString.split(",") : []), + [calendarIdsString] + ); + const dottedEvents: CalendarEvent[] = selectedCalendars.flatMap((calId) => { const calendar = calendars[calId]; if (!calendar?.events) return []; @@ -100,65 +118,99 @@ export default function CalendarApp({ }; useEffect(() => { - if (initialLoadRef.current && Object.keys(calendars || {}).length > 0 && userId) { + if (initialLoadRef.current && calendarIds.length > 0 && userId) { const cached = localStorage.getItem("selectedCalendars"); if (cached && cached.length > 0) { const parsed = JSON.parse(cached) as string[]; const valid = parsed.filter((id) => calendars[id]); setSelectedCalendars(valid); } else { - const personalCalendarIds = Object.keys(calendars).filter( + const personalCalendarIds = calendarIds.filter( (id) => id.split("/")[0] === userId ); setSelectedCalendars(personalCalendarIds); } initialLoadRef.current = false; } - }, [calendars, userId]); + }, [calendarIds, calendars, userId]); // Save selected cals to cache useEffect(() => { - if (Object.keys(calendars || {}).length > 0) { + if (calendarIds.length > 0) { localStorage.setItem( "selectedCalendars", JSON.stringify(selectedCalendars) ); } - }, [selectedCalendars]); + }, [selectedCalendars, calendarIds.length]); + + const prevCalendarLightSignature = useRef(null); useEffect(() => { + if (!calendarLightSignature) { + prevCalendarLightSignature.current = calendarLightSignature; + return; + } + + if (prevCalendarLightSignature.current === calendarLightSignature) { + return; + } + + prevCalendarLightSignature.current = calendarLightSignature; updateDarkColor(calendars || {}, theme, dispatch); - }, [ - theme, - Object.values(calendars || {}) - .map((c) => c.color?.dark) - .join(","), - ]); + }, [calendarLightSignature, calendars, theme, dispatch]); useEffect(() => { - const validCalendarIds = new Set(Object.keys(calendars || {})); - setSelectedCalendars((prev) => - prev.filter((calId) => validCalendarIds.has(calId)) - ); - }, [calendars]); + if (calendarIds.length === 0) return; + const validCalendarIds = new Set(calendarIds); + setSelectedCalendars((prev) => { + const filtered = prev.filter((calId) => validCalendarIds.has(calId)); + if (filtered.length === prev.length) { + const unchanged = filtered.every((id, index) => id === prev[index]); + if (unchanged) { + return prev; + } + } + return filtered; + }); + }, [calendarIds]); const calendarRange = useMemo( () => getCalendarRange(selectedDate), [selectedDate] ); + const calendarRangeStart = calendarRange.start.getTime(); + const calendarRangeEnd = calendarRange.end.getTime(); + + const rangeStart = useMemo( + () => formatDateToYYYYMMDDTHHMMSS(new Date(calendarRangeStart)), + [calendarRangeStart] + ); + + const rangeEnd = useMemo( + () => formatDateToYYYYMMDDTHHMMSS(new Date(calendarRangeEnd)), + [calendarRangeEnd] + ); + // Create a stable string key for the range - const rangeKey = `${formatDateToYYYYMMDDTHHMMSS( - calendarRange.start - )}_${formatDateToYYYYMMDDTHHMMSS(calendarRange.end)}`; + const rangeKey = useMemo( + () => `${rangeStart}_${rangeEnd}`, + [rangeStart, rangeEnd] + ); let filteredEvents: CalendarEvent[] = extractEvents( selectedCalendars, calendars || {} ); + const tempCalendarIds = useMemo( + () => Object.keys(tempcalendars || {}).sort(), + [tempcalendars] + ); + let filteredTempEvents: CalendarEvent[] = extractEvents( - Object.keys(tempcalendars || {}), + tempCalendarIds, tempcalendars || {} ); @@ -169,55 +221,73 @@ export default function CalendarApp({ const prefetchedCalendarsRef = useRef>({}); const tempFetchedRangesRef = useRef>({}); + const activeLoadCompletedRef = useRef(false); + const [activeLoadCompleted, setActiveLoadCompleted] = + useState(false); useEffect(() => { - if (!rangeKey || sortedSelectedCalendars.length === 0) return; + activeLoadCompletedRef.current = false; + setActiveLoadCompleted(false); + }, [rangeKey]); + + useEffect(() => { + if (!rangeKey || sortedSelectedCalendars.length === 0) { + activeLoadCompletedRef.current = true; + setActiveLoadCompleted(true); + return; + } let cancelled = false; const ACTIVE_BATCH_SIZE = 5; const loadCalendars = async () => { - const pendingIds = sortedSelectedCalendars.filter( - (id) => fetchedRangesRef.current[id] !== rangeKey - ); + try { + const pendingIds = sortedSelectedCalendars.filter( + (id) => fetchedRangesRef.current[id] !== rangeKey + ); - if (pendingIds.length === 0) { - return; - } + if (pendingIds.length === 0) { + activeLoadCompletedRef.current = true; + setActiveLoadCompleted(true); + return; + } - const rangeStart = formatDateToYYYYMMDDTHHMMSS(calendarRange.start); - const rangeEnd = formatDateToYYYYMMDDTHHMMSS(calendarRange.end); + for ( + let i = 0; + i < pendingIds.length && !cancelled; + i += ACTIVE_BATCH_SIZE + ) { + const chunk = pendingIds.slice(i, i + ACTIVE_BATCH_SIZE); - for ( - let i = 0; - i < pendingIds.length && !cancelled; - i += ACTIVE_BATCH_SIZE - ) { - const chunk = pendingIds.slice(i, i + ACTIVE_BATCH_SIZE); + chunk.forEach((id) => { + fetchedRangesRef.current[id] = rangeKey; + prefetchedCalendarsRef.current[id] = "active"; + }); - chunk.forEach((id) => { - fetchedRangesRef.current[id] = rangeKey; - prefetchedCalendarsRef.current[id] = "active"; - }); + const requests = chunk.map(async (id) => { + try { + await dispatch( + getCalendarDetailAsync({ + calId: id, + match: { + start: rangeStart, + end: rangeEnd, + }, + }) + ).unwrap(); + } catch (error) { + console.error(`Failed to load calendar ${id}:`, error); + fetchedRangesRef.current[id] = ""; + } + }); - const requests = chunk.map(async (id) => { - try { - await dispatch( - getCalendarDetailAsync({ - calId: id, - match: { - start: rangeStart, - end: rangeEnd, - }, - }) - ).unwrap(); - } catch (error) { - console.error(`Failed to load calendar ${id}:`, error); - fetchedRangesRef.current[id] = ""; - } - }); - - await Promise.all(requests); + await Promise.all(requests); + } + } finally { + if (!cancelled) { + activeLoadCompletedRef.current = true; + setActiveLoadCompleted(true); + } } }; @@ -226,22 +296,19 @@ export default function CalendarApp({ return () => { cancelled = true; }; - }, [ - dispatch, - rangeKey, - sortedSelectedCalendars, - calendarRange, - ]); + }, [dispatch, rangeKey, sortedSelectedCalendars, rangeStart, rangeEnd]); useEffect(() => { - if (!rangeKey) return; + if (!rangeKey || !activeLoadCompleted) return; - const rangeStart = formatDateToYYYYMMDDTHHMMSS(calendarRange.start); - const rangeEnd = formatDateToYYYYMMDDTHHMMSS(calendarRange.end); - - const hiddenCalendars = Object.keys(calendars || {}) + const hiddenCalendars = calendarIds .filter((id) => !selectedCalendars.includes(id)) - .filter((id) => prefetchedCalendarsRef.current[id] !== rangeKey); + .filter((id) => { + const prefetched = prefetchedCalendarsRef.current[id]; + return prefetched !== rangeKey && prefetched !== "active"; + }); + + if (hiddenCalendars.length === 0) return; hiddenCalendars.forEach((id) => { prefetchedCalendarsRef.current[id] = rangeKey; @@ -261,43 +328,56 @@ export default function CalendarApp({ }); }); }, [ - calendars, + calendarIdsString, selectedCalendars, rangeKey, - calendarRange, dispatch, + rangeStart, + rangeEnd, + activeLoadCompleted, ]); + const calendarsWithClearedCache = useMemo(() => { + return selectedCalendars + .map((id) => { + const cleared = calendars[id]?.lastCacheCleared; + return cleared ? { id, cleared } : null; + }) + .filter(Boolean) as { id: string; cleared: number }[]; + }, [selectedCalendars, calendars]); + + const processedCacheClearRef = useRef>({}); + useEffect(() => { - selectedCalendars.forEach((calId) => { - const calendar = calendars[calId]; - if (calendar?.lastCacheCleared) { - delete fetchedRangesRef.current[calId]; - prefetchedCalendarsRef.current[calId] = ""; - - dispatch( - getCalendarDetailAsync({ - calId, - match: { - start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start), - end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end), - }, - }) - ); - - fetchedRangesRef.current[calId] = rangeKey; - prefetchedCalendarsRef.current[calId] = rangeKey; + calendarsWithClearedCache.forEach(({ id, cleared }) => { + if (processedCacheClearRef.current[id] === cleared) { + return; } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - selectedCalendars.map((id) => calendars[id]?.lastCacheCleared).join(","), - ]); - const tempCalendarIds = useMemo( - () => Object.keys(tempcalendars || {}).sort(), - [tempcalendars] - ); + processedCacheClearRef.current[id] = cleared; + delete fetchedRangesRef.current[id]; + prefetchedCalendarsRef.current[id] = ""; + + dispatch( + getCalendarDetailAsync({ + calId: id, + match: { + start: rangeStart, + end: rangeEnd, + }, + }) + ) + .unwrap() + .then(() => { + fetchedRangesRef.current[id] = rangeKey; + prefetchedCalendarsRef.current[id] = rangeKey; + }) + .catch(() => { + fetchedRangesRef.current[id] = ""; + prefetchedCalendarsRef.current[id] = ""; + }); + }); + }, [calendarsWithClearedCache, dispatch, rangeKey, rangeStart, rangeEnd]); const tempCalendarControllersRef = useRef>( new Map() @@ -317,9 +397,6 @@ export default function CalendarApp({ let cancelled = false; const TEMP_BATCH_SIZE = 5; - const rangeStart = formatDateToYYYYMMDDTHHMMSS(calendarRange.start); - const rangeEnd = formatDateToYYYYMMDDTHHMMSS(calendarRange.end); - const loadTempCalendars = async () => { const pendingIds = tempCalendarIds.filter( (id) => tempFetchedRangesRef.current[id] !== rangeKey @@ -366,7 +443,7 @@ export default function CalendarApp({ return () => { cancelled = true; }; - }, [dispatch, rangeKey, tempCalendarIds, calendarRange]); + }, [dispatch, rangeKey, tempCalendarIds, rangeStart, rangeEnd]); const [anchorEl, setAnchorEl] = useState(null); diff --git a/src/components/Calendar/CalendarSelection.tsx b/src/components/Calendar/CalendarSelection.tsx index 38fbae2..ffc30b6 100644 --- a/src/components/Calendar/CalendarSelection.tsx +++ b/src/components/Calendar/CalendarSelection.tsx @@ -106,14 +106,14 @@ export default function CalendarSelection({ useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; const calendars = useAppSelector((state) => state.calendars.list); - const personalCalendars = Object.keys(calendars).filter( + const personalCalendars = Object.keys(calendars || {}).filter( (id) => id.split("/")[0] === userId ); - const delegatedCalendars = Object.keys(calendars).filter( - (id) => id.split("/")[0] !== userId && calendars[id].delegated + const delegatedCalendars = Object.keys(calendars || {}).filter( + (id) => id.split("/")[0] !== userId && calendars[id]?.delegated ); - const sharedCalendars = Object.keys(calendars).filter( - (id) => id.split("/")[0] !== userId && !calendars[id].delegated + const sharedCalendars = Object.keys(calendars || {}).filter( + (id) => id.split("/")[0] !== userId && !calendars?.[id]?.delegated ); const handleCalendarToggle = (name: string) => { @@ -178,7 +178,7 @@ export default function CalendarSelection({ { setSelectedCalId(""); setAnchorElCal(null); diff --git a/src/components/Calendar/MiniCalendar.tsx b/src/components/Calendar/MiniCalendar.tsx index 92910a1..a9164c9 100644 --- a/src/components/Calendar/MiniCalendar.tsx +++ b/src/components/Calendar/MiniCalendar.tsx @@ -51,7 +51,7 @@ export function MiniCalendar({ onMonthChange={(month) => { const calendarRange = getCalendarRange(month.toDate()); setVisibleDate(month.toDate()); - Object.values(calendars.list).forEach((cal) => + Object.values(calendars.list || {}).forEach((cal) => dispatch( getCalendarDetailAsync({ calId: cal.id, diff --git a/src/components/Calendar/hooks/useCalendarEventHandlers.ts b/src/components/Calendar/hooks/useCalendarEventHandlers.ts index b0f409c..5a2f33b 100644 --- a/src/components/Calendar/hooks/useCalendarEventHandlers.ts +++ b/src/components/Calendar/hooks/useCalendarEventHandlers.ts @@ -34,6 +34,7 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => { props.setEventDisplayedCalId, props.setEventDisplayedTemp, props.calendars, + props.dispatch, ]), handleEventAllow: useCallback(eventHandlers.handleEventAllow, []), handleEventDrop: useCallback(eventHandlers.handleEventDrop, [ @@ -42,10 +43,14 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => { props.setSelectedEvent, props.setOpenEditModePopup, props.setAfterChoiceFunc, + props.tempcalendars, + props.calendarRange, ]), handleEventResize: useCallback(eventHandlers.handleEventResize, [ props.calendars, props.dispatch, + props.tempcalendars, + props.calendarRange, ]), }; }; diff --git a/src/components/Calendar/utils/calendarColorsUtils.tsx b/src/components/Calendar/utils/calendarColorsUtils.tsx index b8abb45..7092406 100644 --- a/src/components/Calendar/utils/calendarColorsUtils.tsx +++ b/src/components/Calendar/utils/calendarColorsUtils.tsx @@ -19,6 +19,10 @@ export function updateDarkColor( ? isDefault.dark : getAccessiblePair(baseColor, theme); + if (cal.color?.dark === darkColor) { + return; + } + dispatch( updateCalColor({ id: cal.id, diff --git a/src/components/Menubar/Menubar.tsx b/src/components/Menubar/Menubar.tsx index 5c87feb..9d92849 100644 --- a/src/components/Menubar/Menubar.tsx +++ b/src/components/Menubar/Menubar.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import logo from "../../static/header-logo.svg"; import AppsIcon from "@mui/icons-material/Apps"; import RefreshIcon from "@mui/icons-material/Refresh"; @@ -53,8 +53,14 @@ export function Menubar({ const [langAnchorEl, setLangAnchorEl] = useState(null); const dispatch = useAppDispatch(); + useEffect(() => { + if (!user) { + dispatch(push("/")); + } + }, [dispatch, user]); + if (!user) { - dispatch(push("/")); + return null; } const handleOpen = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index c1ae324..2cd56b9 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -40,8 +40,16 @@ interface RejectedError { export const getCalendarsListAsync = createAsyncThunk< { importedCalendars: Record; errors: string }, // Return type void, // Arg type - { rejectValue: RejectedError } // ThunkAPI config ->("calendars/getCalendars", async (_, { rejectWithValue }) => { + { rejectValue: RejectedError; state: any } // ThunkAPI config +>("calendars/getCalendars", async (_, { rejectWithValue, getState }) => { + const state = getState() as any; + if (Object.keys(state.calendars.list).length > 0) { + return { + importedCalendars: state.calendars.list, + errors: "", + }; + } + try { const importedCalendars: Record = {}; const user = (await getOpenPaasUser()) as Record; @@ -106,15 +114,7 @@ export const getCalendarsListAsync = createAsyncThunk< } normalizedCalendars.forEach( - ({ - cal, - description, - delegated, - link, - id, - ownerId, - visibility, - }) => { + ({ cal, description, delegated, link, id, ownerId, visibility }) => { const ownerData = ownerDataMap.get(ownerId) || { firstname: "", lastname: "Unknown User", diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index 0eaf6f1..096cf7b 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -59,10 +59,10 @@ function EventPopover({ const selectPersonalCalendars = createSelector( (state: any) => state.calendars, (calendars: any) => - Object.keys(calendars.list) + Object.keys(calendars.list || {}) .map((id) => { if (id.split("/")[0] === userId) { - return calendars.list[id]; + return calendars.list?.[id]; } return {} as Calendars; }) diff --git a/src/features/Events/ImportAlert.tsx b/src/features/Events/ImportAlert.tsx index 70a32d6..6f359f6 100644 --- a/src/features/Events/ImportAlert.tsx +++ b/src/features/Events/ImportAlert.tsx @@ -17,8 +17,8 @@ export default function ImportAlert() { return ( <> - {Object.keys(calendars).map((calendarId) => - calendars[calendarId]?.events + {Object.keys(calendars || {}).map((calendarId) => + calendars?.[calendarId]?.events ? Object.keys(calendars[calendarId]?.events) .filter((id) => calendars[calendarId]?.events[id].error) .map((id) => { diff --git a/src/features/User/HandleLogin.tsx b/src/features/User/HandleLogin.tsx index 9a87eba..c7df37f 100644 --- a/src/features/User/HandleLogin.tsx +++ b/src/features/User/HandleLogin.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useRef } from "react"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { Auth } from "./oidcAuth"; import { Loading } from "../../components/Loading/Loading"; @@ -11,41 +11,50 @@ export function HandleLogin() { const userData = useAppSelector((state) => state.user); const calendars = useAppSelector((state) => state.calendars); const dispatch = useAppDispatch(); + const hasInitiatedRef = useRef(false); + const calendarsLoadingRef = useRef(false); + useEffect(() => { + if (hasInitiatedRef.current) return; + if (userData.userData) return; + if (Object.keys(calendars.list).length > 0) return; + if (calendarsLoadingRef.current) return; + + hasInitiatedRef.current = true; const initiateLogin = async () => { - if (!userData.userData) { - const savedToken = sessionStorage.getItem("tokenSet") - ? JSON.parse(sessionStorage.getItem("tokenSet")!) - : null; - const savedUser = sessionStorage.getItem("userData") - ? JSON.parse(sessionStorage.getItem("userData")!) - : null; + const savedToken = sessionStorage.getItem("tokenSet") + ? JSON.parse(sessionStorage.getItem("tokenSet")!) + : null; + const savedUser = sessionStorage.getItem("userData") + ? JSON.parse(sessionStorage.getItem("userData")!) + : null; - if (savedToken && savedUser) { - dispatch(setTokens(savedToken)); - dispatch(setUserData(savedUser)); - await dispatch(getOpenPaasUserDataAsync()); - await dispatch(getCalendarsListAsync()); - dispatch(push("/calendar")); - return; - } - - const loginurl = await Auth(); - - sessionStorage.setItem( - "redirectState", - JSON.stringify({ - code_verifier: loginurl.code_verifier, - state: loginurl.state, - }) - ); - - redirectTo(loginurl.redirectTo); + if (savedToken && savedUser) { + dispatch(setTokens(savedToken)); + dispatch(setUserData(savedUser)); + await dispatch(getOpenPaasUserDataAsync()); + calendarsLoadingRef.current = true; + await dispatch(getCalendarsListAsync()); + calendarsLoadingRef.current = false; + dispatch(push("/calendar")); + return; } + + const loginurl = await Auth(); + + sessionStorage.setItem( + "redirectState", + JSON.stringify({ + code_verifier: loginurl.code_verifier, + state: loginurl.state, + }) + ); + + redirectTo(loginurl.redirectTo); }; initiateLogin(); - }, [userData, dispatch]); + }, [userData.userData, calendars.list, dispatch]); useEffect(() => { if (userData.error) { dispatch(push("/error")); @@ -53,7 +62,7 @@ export function HandleLogin() { if (!calendars.pending && !userData.loading && !userData.error) { dispatch(push("/calendar")); } - }, [calendars.pending, userData.loading]); + }, [calendars.pending, userData.loading, userData.error, dispatch]); return ; } diff --git a/src/features/User/LoginCallback.tsx b/src/features/User/LoginCallback.tsx index 2220cf3..e5b5574 100644 --- a/src/features/User/LoginCallback.tsx +++ b/src/features/User/LoginCallback.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; import { Callback } from "./oidcAuth"; -import { useAppDispatch } from "../../app/hooks"; +import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { push } from "redux-first-history"; import { getOpenPaasUserDataAsync, setTokens, setUserData } from "./userSlice"; import { Loading } from "../../components/Loading/Loading"; @@ -9,6 +9,7 @@ import { getCalendarsListAsync } from "../Calendars/CalendarSlice"; export function CallbackResume() { const dispatch = useAppDispatch(); const hasRun = useRef(false); + const calendarsLoadingRef = useRef(false); const saved = sessionStorage.getItem("redirectState") ? JSON.parse(sessionStorage.getItem("redirectState")!) : null; @@ -23,7 +24,11 @@ export function CallbackResume() { dispatch(setUserData(data?.userinfo)); dispatch(setTokens(data?.tokenSet)); await dispatch(getOpenPaasUserDataAsync()); - await dispatch(getCalendarsListAsync()); + if (!calendarsLoadingRef.current) { + calendarsLoadingRef.current = true; + await dispatch(getCalendarsListAsync()); + calendarsLoadingRef.current = false; + } sessionStorage.removeItem("redirectState"); sessionStorage.setItem("tokenSet", JSON.stringify(data?.tokenSet));