refactor: stabilize calendar loading effects and menubar redirect

- Memoize calendar range strings and stabilize effect deps
- Guard updateDarkColor to avoid dispatch loops
- Add resilient cache-clear handling with refs
- Memoize calendar/temp ids to prevent rerenders
- Update event handler hooks with missing deps
- Add guards and dependency fixes across Calendar effects
This commit is contained in:
lenhanphung
2025-11-07 16:42:06 +07:00
committed by Benoit TELLIER
parent 990e290066
commit ffe204d60b
13 changed files with 650 additions and 163 deletions
+184
View File
@@ -581,4 +581,188 @@ describe("calendar Availability search", () => {
expect(startDate.getTime()).toBeLessThanOrEqual(viewStart.getTime()); expect(startDate.getTime()).toBeLessThanOrEqual(viewStart.getTime());
expect(endDate.getTime()).toBeGreaterThanOrEqual(viewEnd.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(
<CalendarApp calendarRef={{ current: null }} />,
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(
<CalendarApp calendarRef={{ current: null }} />,
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(
<CalendarApp calendarRef={{ current: null }} />,
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(
<CalendarApp calendarRef={{ current: null }} />,
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(
<CalendarApp calendarRef={{ current: null }} />,
stateWithUndefinedTemp
);
});
expect(screen.getByText("calendar.personal")).toBeInTheDocument();
});
});
}); });
@@ -140,6 +140,203 @@ describe("CalendarSlice", () => {
expect(state.list).toEqual({}); 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", () => { it("patchCalendarAsync.fulfilled updates calendar fields", () => {
const calId = "c1"; const calId = "c1";
const prev = { const prev = {
+184 -107
View File
@@ -5,7 +5,7 @@ import interactionPlugin from "@fullcalendar/interaction";
import { CalendarApi, DateSelectArg } from "@fullcalendar/core"; import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
import "./Calendar.styl"; import "./Calendar.styl";
import "./CustomCalendar.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 { useAppDispatch, useAppSelector } from "../../app/hooks";
import EventPopover from "../../features/Events/EventModal"; import EventPopover from "../../features/Events/EventModal";
import { CalendarEvent } from "../../features/Events/EventsTypes"; import { CalendarEvent } from "../../features/Events/EventsTypes";
@@ -44,7 +44,7 @@ import { updateDarkColor } from "./utils/calendarColorsUtils";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
interface CalendarAppProps { interface CalendarAppProps {
calendarRef: React.RefObject<CalendarApi | null>; calendarRef: MutableRefObject<CalendarApi | null>;
onDateChange?: (date: Date) => void; onDateChange?: (date: Date) => void;
onViewChange?: (view: string) => void; onViewChange?: (view: string) => void;
} }
@@ -65,13 +65,31 @@ export default function CalendarApp({
if (!tokens || !userId) { if (!tokens || !userId) {
dispatch(push("/")); dispatch(push("/"));
} }
}, [tokens, userId]); }, [dispatch, tokens, userId]);
const calendars = useAppSelector((state) => state.calendars.list); const calendars = useAppSelector((state) => state.calendars.list);
const tempcalendars = const tempcalendars = useAppSelector((state) => state.calendars.templist);
useAppSelector((state) => state.calendars.templist) ?? {};
const [selectedCalendars, setSelectedCalendars] = useState<string[]>([]); const [selectedCalendars, setSelectedCalendars] = useState<string[]>([]);
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 dottedEvents: CalendarEvent[] = selectedCalendars.flatMap((calId) => {
const calendar = calendars[calId]; const calendar = calendars[calId];
if (!calendar?.events) return []; if (!calendar?.events) return [];
@@ -100,65 +118,99 @@ export default function CalendarApp({
}; };
useEffect(() => { useEffect(() => {
if (initialLoadRef.current && Object.keys(calendars || {}).length > 0 && userId) { if (initialLoadRef.current && calendarIds.length > 0 && userId) {
const cached = localStorage.getItem("selectedCalendars"); const cached = localStorage.getItem("selectedCalendars");
if (cached && cached.length > 0) { if (cached && cached.length > 0) {
const parsed = JSON.parse(cached) as string[]; const parsed = JSON.parse(cached) as string[];
const valid = parsed.filter((id) => calendars[id]); const valid = parsed.filter((id) => calendars[id]);
setSelectedCalendars(valid); setSelectedCalendars(valid);
} else { } else {
const personalCalendarIds = Object.keys(calendars).filter( const personalCalendarIds = calendarIds.filter(
(id) => id.split("/")[0] === userId (id) => id.split("/")[0] === userId
); );
setSelectedCalendars(personalCalendarIds); setSelectedCalendars(personalCalendarIds);
} }
initialLoadRef.current = false; initialLoadRef.current = false;
} }
}, [calendars, userId]); }, [calendarIds, calendars, userId]);
// Save selected cals to cache // Save selected cals to cache
useEffect(() => { useEffect(() => {
if (Object.keys(calendars || {}).length > 0) { if (calendarIds.length > 0) {
localStorage.setItem( localStorage.setItem(
"selectedCalendars", "selectedCalendars",
JSON.stringify(selectedCalendars) JSON.stringify(selectedCalendars)
); );
} }
}, [selectedCalendars]); }, [selectedCalendars, calendarIds.length]);
const prevCalendarLightSignature = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (!calendarLightSignature) {
prevCalendarLightSignature.current = calendarLightSignature;
return;
}
if (prevCalendarLightSignature.current === calendarLightSignature) {
return;
}
prevCalendarLightSignature.current = calendarLightSignature;
updateDarkColor(calendars || {}, theme, dispatch); updateDarkColor(calendars || {}, theme, dispatch);
}, [ }, [calendarLightSignature, calendars, theme, dispatch]);
theme,
Object.values(calendars || {})
.map((c) => c.color?.dark)
.join(","),
]);
useEffect(() => { useEffect(() => {
const validCalendarIds = new Set(Object.keys(calendars || {})); if (calendarIds.length === 0) return;
setSelectedCalendars((prev) => const validCalendarIds = new Set(calendarIds);
prev.filter((calId) => validCalendarIds.has(calId)) setSelectedCalendars((prev) => {
); const filtered = prev.filter((calId) => validCalendarIds.has(calId));
}, [calendars]); if (filtered.length === prev.length) {
const unchanged = filtered.every((id, index) => id === prev[index]);
if (unchanged) {
return prev;
}
}
return filtered;
});
}, [calendarIds]);
const calendarRange = useMemo( const calendarRange = useMemo(
() => getCalendarRange(selectedDate), () => getCalendarRange(selectedDate),
[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 // Create a stable string key for the range
const rangeKey = `${formatDateToYYYYMMDDTHHMMSS( const rangeKey = useMemo(
calendarRange.start () => `${rangeStart}_${rangeEnd}`,
)}_${formatDateToYYYYMMDDTHHMMSS(calendarRange.end)}`; [rangeStart, rangeEnd]
);
let filteredEvents: CalendarEvent[] = extractEvents( let filteredEvents: CalendarEvent[] = extractEvents(
selectedCalendars, selectedCalendars,
calendars || {} calendars || {}
); );
const tempCalendarIds = useMemo(
() => Object.keys(tempcalendars || {}).sort(),
[tempcalendars]
);
let filteredTempEvents: CalendarEvent[] = extractEvents( let filteredTempEvents: CalendarEvent[] = extractEvents(
Object.keys(tempcalendars || {}), tempCalendarIds,
tempcalendars || {} tempcalendars || {}
); );
@@ -169,55 +221,73 @@ export default function CalendarApp({
const prefetchedCalendarsRef = useRef<Record<string, string>>({}); const prefetchedCalendarsRef = useRef<Record<string, string>>({});
const tempFetchedRangesRef = useRef<Record<string, string>>({}); const tempFetchedRangesRef = useRef<Record<string, string>>({});
const activeLoadCompletedRef = useRef<boolean>(false);
const [activeLoadCompleted, setActiveLoadCompleted] =
useState<boolean>(false);
useEffect(() => { 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; let cancelled = false;
const ACTIVE_BATCH_SIZE = 5; const ACTIVE_BATCH_SIZE = 5;
const loadCalendars = async () => { const loadCalendars = async () => {
const pendingIds = sortedSelectedCalendars.filter( try {
(id) => fetchedRangesRef.current[id] !== rangeKey const pendingIds = sortedSelectedCalendars.filter(
); (id) => fetchedRangesRef.current[id] !== rangeKey
);
if (pendingIds.length === 0) { if (pendingIds.length === 0) {
return; activeLoadCompletedRef.current = true;
} setActiveLoadCompleted(true);
return;
}
const rangeStart = formatDateToYYYYMMDDTHHMMSS(calendarRange.start); for (
const rangeEnd = formatDateToYYYYMMDDTHHMMSS(calendarRange.end); let i = 0;
i < pendingIds.length && !cancelled;
i += ACTIVE_BATCH_SIZE
) {
const chunk = pendingIds.slice(i, i + ACTIVE_BATCH_SIZE);
for ( chunk.forEach((id) => {
let i = 0; fetchedRangesRef.current[id] = rangeKey;
i < pendingIds.length && !cancelled; prefetchedCalendarsRef.current[id] = "active";
i += ACTIVE_BATCH_SIZE });
) {
const chunk = pendingIds.slice(i, i + ACTIVE_BATCH_SIZE);
chunk.forEach((id) => { const requests = chunk.map(async (id) => {
fetchedRangesRef.current[id] = rangeKey; try {
prefetchedCalendarsRef.current[id] = "active"; 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) => { await Promise.all(requests);
try { }
await dispatch( } finally {
getCalendarDetailAsync({ if (!cancelled) {
calId: id, activeLoadCompletedRef.current = true;
match: { setActiveLoadCompleted(true);
start: rangeStart, }
end: rangeEnd,
},
})
).unwrap();
} catch (error) {
console.error(`Failed to load calendar ${id}:`, error);
fetchedRangesRef.current[id] = "";
}
});
await Promise.all(requests);
} }
}; };
@@ -226,22 +296,19 @@ export default function CalendarApp({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [ }, [dispatch, rangeKey, sortedSelectedCalendars, rangeStart, rangeEnd]);
dispatch,
rangeKey,
sortedSelectedCalendars,
calendarRange,
]);
useEffect(() => { useEffect(() => {
if (!rangeKey) return; if (!rangeKey || !activeLoadCompleted) return;
const rangeStart = formatDateToYYYYMMDDTHHMMSS(calendarRange.start); const hiddenCalendars = calendarIds
const rangeEnd = formatDateToYYYYMMDDTHHMMSS(calendarRange.end);
const hiddenCalendars = Object.keys(calendars || {})
.filter((id) => !selectedCalendars.includes(id)) .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) => { hiddenCalendars.forEach((id) => {
prefetchedCalendarsRef.current[id] = rangeKey; prefetchedCalendarsRef.current[id] = rangeKey;
@@ -261,43 +328,56 @@ export default function CalendarApp({
}); });
}); });
}, [ }, [
calendars, calendarIdsString,
selectedCalendars, selectedCalendars,
rangeKey, rangeKey,
calendarRange,
dispatch, 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<Record<string, number>>({});
useEffect(() => { useEffect(() => {
selectedCalendars.forEach((calId) => { calendarsWithClearedCache.forEach(({ id, cleared }) => {
const calendar = calendars[calId]; if (processedCacheClearRef.current[id] === cleared) {
if (calendar?.lastCacheCleared) { return;
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;
} }
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
selectedCalendars.map((id) => calendars[id]?.lastCacheCleared).join(","),
]);
const tempCalendarIds = useMemo( processedCacheClearRef.current[id] = cleared;
() => Object.keys(tempcalendars || {}).sort(), delete fetchedRangesRef.current[id];
[tempcalendars] 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<Map<string, AbortController>>( const tempCalendarControllersRef = useRef<Map<string, AbortController>>(
new Map() new Map()
@@ -317,9 +397,6 @@ export default function CalendarApp({
let cancelled = false; let cancelled = false;
const TEMP_BATCH_SIZE = 5; const TEMP_BATCH_SIZE = 5;
const rangeStart = formatDateToYYYYMMDDTHHMMSS(calendarRange.start);
const rangeEnd = formatDateToYYYYMMDDTHHMMSS(calendarRange.end);
const loadTempCalendars = async () => { const loadTempCalendars = async () => {
const pendingIds = tempCalendarIds.filter( const pendingIds = tempCalendarIds.filter(
(id) => tempFetchedRangesRef.current[id] !== rangeKey (id) => tempFetchedRangesRef.current[id] !== rangeKey
@@ -366,7 +443,7 @@ export default function CalendarApp({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [dispatch, rangeKey, tempCalendarIds, calendarRange]); }, [dispatch, rangeKey, tempCalendarIds, rangeStart, rangeEnd]);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
@@ -106,14 +106,14 @@ export default function CalendarSelection({
useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const calendars = useAppSelector((state) => state.calendars.list); const calendars = useAppSelector((state) => state.calendars.list);
const personalCalendars = Object.keys(calendars).filter( const personalCalendars = Object.keys(calendars || {}).filter(
(id) => id.split("/")[0] === userId (id) => id.split("/")[0] === userId
); );
const delegatedCalendars = Object.keys(calendars).filter( const delegatedCalendars = Object.keys(calendars || {}).filter(
(id) => id.split("/")[0] !== userId && calendars[id].delegated (id) => id.split("/")[0] !== userId && calendars[id]?.delegated
); );
const sharedCalendars = Object.keys(calendars).filter( const sharedCalendars = Object.keys(calendars || {}).filter(
(id) => id.split("/")[0] !== userId && !calendars[id].delegated (id) => id.split("/")[0] !== userId && !calendars?.[id]?.delegated
); );
const handleCalendarToggle = (name: string) => { const handleCalendarToggle = (name: string) => {
@@ -178,7 +178,7 @@ export default function CalendarSelection({
</div> </div>
<CalendarPopover <CalendarPopover
open={Boolean(anchorElCal)} open={Boolean(anchorElCal)}
calendar={calendars[selectedCalId] ?? undefined} calendar={calendars?.[selectedCalId] ?? undefined}
onClose={() => { onClose={() => {
setSelectedCalId(""); setSelectedCalId("");
setAnchorElCal(null); setAnchorElCal(null);
+1 -1
View File
@@ -51,7 +51,7 @@ export function MiniCalendar({
onMonthChange={(month) => { onMonthChange={(month) => {
const calendarRange = getCalendarRange(month.toDate()); const calendarRange = getCalendarRange(month.toDate());
setVisibleDate(month.toDate()); setVisibleDate(month.toDate());
Object.values(calendars.list).forEach((cal) => Object.values(calendars.list || {}).forEach((cal) =>
dispatch( dispatch(
getCalendarDetailAsync({ getCalendarDetailAsync({
calId: cal.id, calId: cal.id,
@@ -34,6 +34,7 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => {
props.setEventDisplayedCalId, props.setEventDisplayedCalId,
props.setEventDisplayedTemp, props.setEventDisplayedTemp,
props.calendars, props.calendars,
props.dispatch,
]), ]),
handleEventAllow: useCallback(eventHandlers.handleEventAllow, []), handleEventAllow: useCallback(eventHandlers.handleEventAllow, []),
handleEventDrop: useCallback(eventHandlers.handleEventDrop, [ handleEventDrop: useCallback(eventHandlers.handleEventDrop, [
@@ -42,10 +43,14 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => {
props.setSelectedEvent, props.setSelectedEvent,
props.setOpenEditModePopup, props.setOpenEditModePopup,
props.setAfterChoiceFunc, props.setAfterChoiceFunc,
props.tempcalendars,
props.calendarRange,
]), ]),
handleEventResize: useCallback(eventHandlers.handleEventResize, [ handleEventResize: useCallback(eventHandlers.handleEventResize, [
props.calendars, props.calendars,
props.dispatch, props.dispatch,
props.tempcalendars,
props.calendarRange,
]), ]),
}; };
}; };
@@ -19,6 +19,10 @@ export function updateDarkColor(
? isDefault.dark ? isDefault.dark
: getAccessiblePair(baseColor, theme); : getAccessiblePair(baseColor, theme);
if (cal.color?.dark === darkColor) {
return;
}
dispatch( dispatch(
updateCalColor({ updateCalColor({
id: cal.id, id: cal.id,
+8 -2
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import logo from "../../static/header-logo.svg"; import logo from "../../static/header-logo.svg";
import AppsIcon from "@mui/icons-material/Apps"; import AppsIcon from "@mui/icons-material/Apps";
import RefreshIcon from "@mui/icons-material/Refresh"; import RefreshIcon from "@mui/icons-material/Refresh";
@@ -53,8 +53,14 @@ export function Menubar({
const [langAnchorEl, setLangAnchorEl] = useState<null | HTMLElement>(null); const [langAnchorEl, setLangAnchorEl] = useState<null | HTMLElement>(null);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => {
if (!user) {
dispatch(push("/"));
}
}, [dispatch, user]);
if (!user) { if (!user) {
dispatch(push("/")); return null;
} }
const handleOpen = (event: React.MouseEvent<HTMLElement>) => { const handleOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
+11 -11
View File
@@ -40,8 +40,16 @@ interface RejectedError {
export const getCalendarsListAsync = createAsyncThunk< export const getCalendarsListAsync = createAsyncThunk<
{ importedCalendars: Record<string, Calendars>; errors: string }, // Return type { importedCalendars: Record<string, Calendars>; errors: string }, // Return type
void, // Arg type void, // Arg type
{ rejectValue: RejectedError } // ThunkAPI config { rejectValue: RejectedError; state: any } // ThunkAPI config
>("calendars/getCalendars", async (_, { rejectWithValue }) => { >("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 { 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>;
@@ -106,15 +114,7 @@ export const getCalendarsListAsync = createAsyncThunk<
} }
normalizedCalendars.forEach( normalizedCalendars.forEach(
({ ({ cal, description, delegated, link, id, ownerId, visibility }) => {
cal,
description,
delegated,
link,
id,
ownerId,
visibility,
}) => {
const ownerData = ownerDataMap.get(ownerId) || { const ownerData = ownerDataMap.get(ownerId) || {
firstname: "", firstname: "",
lastname: "Unknown User", lastname: "Unknown User",
+2 -2
View File
@@ -59,10 +59,10 @@ function EventPopover({
const selectPersonalCalendars = createSelector( const selectPersonalCalendars = createSelector(
(state: any) => state.calendars, (state: any) => state.calendars,
(calendars: any) => (calendars: any) =>
Object.keys(calendars.list) Object.keys(calendars.list || {})
.map((id) => { .map((id) => {
if (id.split("/")[0] === userId) { if (id.split("/")[0] === userId) {
return calendars.list[id]; return calendars.list?.[id];
} }
return {} as Calendars; return {} as Calendars;
}) })
+2 -2
View File
@@ -17,8 +17,8 @@ export default function ImportAlert() {
return ( return (
<> <>
{Object.keys(calendars).map((calendarId) => {Object.keys(calendars || {}).map((calendarId) =>
calendars[calendarId]?.events calendars?.[calendarId]?.events
? Object.keys(calendars[calendarId]?.events) ? Object.keys(calendars[calendarId]?.events)
.filter((id) => calendars[calendarId]?.events[id].error) .filter((id) => calendars[calendarId]?.events[id].error)
.map((id) => { .map((id) => {
+39 -30
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from "react"; import React, { useEffect, useRef } from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { Auth } from "./oidcAuth"; import { Auth } from "./oidcAuth";
import { Loading } from "../../components/Loading/Loading"; import { Loading } from "../../components/Loading/Loading";
@@ -11,41 +11,50 @@ export function HandleLogin() {
const userData = useAppSelector((state) => state.user); const userData = useAppSelector((state) => state.user);
const calendars = useAppSelector((state) => state.calendars); const calendars = useAppSelector((state) => state.calendars);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const hasInitiatedRef = useRef(false);
const calendarsLoadingRef = useRef(false);
useEffect(() => { 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 () => { const initiateLogin = async () => {
if (!userData.userData) { const savedToken = sessionStorage.getItem("tokenSet")
const savedToken = sessionStorage.getItem("tokenSet") ? JSON.parse(sessionStorage.getItem("tokenSet")!)
? JSON.parse(sessionStorage.getItem("tokenSet")!) : null;
: null; const savedUser = sessionStorage.getItem("userData")
const savedUser = sessionStorage.getItem("userData") ? JSON.parse(sessionStorage.getItem("userData")!)
? JSON.parse(sessionStorage.getItem("userData")!) : null;
: null;
if (savedToken && savedUser) { if (savedToken && savedUser) {
dispatch(setTokens(savedToken)); dispatch(setTokens(savedToken));
dispatch(setUserData(savedUser)); dispatch(setUserData(savedUser));
await dispatch(getOpenPaasUserDataAsync()); await dispatch(getOpenPaasUserDataAsync());
await dispatch(getCalendarsListAsync()); calendarsLoadingRef.current = true;
dispatch(push("/calendar")); await dispatch(getCalendarsListAsync());
return; 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);
} }
const loginurl = await Auth();
sessionStorage.setItem(
"redirectState",
JSON.stringify({
code_verifier: loginurl.code_verifier,
state: loginurl.state,
})
);
redirectTo(loginurl.redirectTo);
}; };
initiateLogin(); initiateLogin();
}, [userData, dispatch]); }, [userData.userData, calendars.list, dispatch]);
useEffect(() => { useEffect(() => {
if (userData.error) { if (userData.error) {
dispatch(push("/error")); dispatch(push("/error"));
@@ -53,7 +62,7 @@ export function HandleLogin() {
if (!calendars.pending && !userData.loading && !userData.error) { if (!calendars.pending && !userData.loading && !userData.error) {
dispatch(push("/calendar")); dispatch(push("/calendar"));
} }
}, [calendars.pending, userData.loading]); }, [calendars.pending, userData.loading, userData.error, dispatch]);
return <Loading />; return <Loading />;
} }
+7 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Callback } from "./oidcAuth"; import { Callback } from "./oidcAuth";
import { useAppDispatch } from "../../app/hooks"; import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { push } from "redux-first-history"; import { push } from "redux-first-history";
import { getOpenPaasUserDataAsync, setTokens, setUserData } from "./userSlice"; import { getOpenPaasUserDataAsync, setTokens, setUserData } from "./userSlice";
import { Loading } from "../../components/Loading/Loading"; import { Loading } from "../../components/Loading/Loading";
@@ -9,6 +9,7 @@ import { getCalendarsListAsync } from "../Calendars/CalendarSlice";
export function CallbackResume() { export function CallbackResume() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const hasRun = useRef(false); const hasRun = useRef(false);
const calendarsLoadingRef = useRef(false);
const saved = sessionStorage.getItem("redirectState") const saved = sessionStorage.getItem("redirectState")
? JSON.parse(sessionStorage.getItem("redirectState")!) ? JSON.parse(sessionStorage.getItem("redirectState")!)
: null; : null;
@@ -23,7 +24,11 @@ export function CallbackResume() {
dispatch(setUserData(data?.userinfo)); dispatch(setUserData(data?.userinfo));
dispatch(setTokens(data?.tokenSet)); dispatch(setTokens(data?.tokenSet));
await dispatch(getOpenPaasUserDataAsync()); await dispatch(getOpenPaasUserDataAsync());
await dispatch(getCalendarsListAsync()); if (!calendarsLoadingRef.current) {
calendarsLoadingRef.current = true;
await dispatch(getCalendarsListAsync());
calendarsLoadingRef.current = false;
}
sessionStorage.removeItem("redirectState"); sessionStorage.removeItem("redirectState");
sessionStorage.setItem("tokenSet", JSON.stringify(data?.tokenSet)); sessionStorage.setItem("tokenSet", JSON.stringify(data?.tokenSet));