From 478a0e0eb050ea971681b4b5b937ef7c68c83d4d Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:06:34 +0100 Subject: [PATCH] 405 back reload token with synctokens (#436) * [#405] added syncToken in calendar params and fetch with sync token * [#405] changed reload to work with sync-token * [#405] fixup promise handling, added calendar adding and removing hanlding with refresh * [#405] fixed event expansion calls * [#405 & refactor] added helperfunction to get base event uid + refactored synctoken updates management * [#405] added pMap lib to process event expansion * [#405] added flag for no synctoken / new synctoken --- __test__/components/Calendar.test.tsx | 11 +- .../features/Calendars/CalendarSlice.test.tsx | 35 +- .../services/refreshCalendar.test.tsx | 674 ++++++++++++++++++ __test__/features/user/LoginCallback.test.tsx | 32 +- jest.config.ts | 2 +- package-lock.json | 13 + package.json | 1 + src/components/Calendar/Calendar.tsx | 5 +- src/components/Calendar/CalendarLayout.tsx | 1 - src/components/Calendar/CalendarModal.tsx | 5 +- src/components/Calendar/CalendarSelection.tsx | 7 +- src/components/Calendar/ImportTab.tsx | 3 +- src/components/Calendar/MiniCalendar.tsx | 2 +- src/components/Calendar/SettingsTab.tsx | 3 +- .../Calendar/handlers/eventHandlers.ts | 2 +- .../Calendar/utils/calendarUtils.ts | 5 +- src/components/Event/utils/eventUtils.tsx | 67 +- src/components/Menubar/EventSearchBar.tsx | 5 +- src/features/Calendars/CalendarSlice.ts | 229 ++---- src/features/Calendars/CalendarTypes.ts | 1 + .../Calendars/api/fetchSyncTokenChanges.ts | 19 + src/features/Calendars/api/types.ts | 22 + .../services/getCalendarDetailAsync.ts | 46 ++ .../services/getCalendarsListAsync.ts | 129 ++++ .../Calendars/services/refreshCalendar.ts | 80 +++ .../Calendars/utils/expandEventFunction.ts | 27 + .../Calendars/utils/extractCalendarEvents.ts | 64 ++ .../Calendars/utils/normalizeCalendar.ts | 27 + .../utils/processSyncTokenUpdates.ts | 35 + src/features/Events/EventApi.ts | 21 +- src/features/Events/EventUpdateModal.tsx | 13 +- src/features/Events/eventUtils.ts | 5 +- src/features/Events/formHelpers.ts | 3 +- src/features/User/HandleLogin.tsx | 4 +- src/features/User/LoginCallback.tsx | 2 +- src/utils/extractEventBaseUuid.ts | 4 + 36 files changed, 1323 insertions(+), 281 deletions(-) create mode 100644 __test__/features/Calendars/services/refreshCalendar.test.tsx create mode 100644 src/features/Calendars/api/fetchSyncTokenChanges.ts create mode 100644 src/features/Calendars/api/types.ts create mode 100644 src/features/Calendars/services/getCalendarDetailAsync.ts create mode 100644 src/features/Calendars/services/getCalendarsListAsync.ts create mode 100644 src/features/Calendars/services/refreshCalendar.ts create mode 100644 src/features/Calendars/utils/expandEventFunction.ts create mode 100644 src/features/Calendars/utils/extractCalendarEvents.ts create mode 100644 src/features/Calendars/utils/normalizeCalendar.ts create mode 100644 src/features/Calendars/utils/processSyncTokenUpdates.ts create mode 100644 src/utils/extractEventBaseUuid.ts diff --git a/__test__/components/Calendar.test.tsx b/__test__/components/Calendar.test.tsx index 135ba6e..7a59d72 100644 --- a/__test__/components/Calendar.test.tsx +++ b/__test__/components/Calendar.test.tsx @@ -3,7 +3,8 @@ import CalendarApp from "../../src/components/Calendar/Calendar"; 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 * as calendarDetailThunks from "../../src/features/Calendars/services/getCalendarDetailAsync"; + import { useRef } from "react"; import userEvent from "@testing-library/user-event"; @@ -552,7 +553,7 @@ describe("calendar Availability search", () => { it("should fetch calendar details with date range matching the displayed month", async () => { const spy = jest - .spyOn(calendarThunks, "getCalendarDetailAsync") + .spyOn(calendarDetailThunks, "getCalendarDetailAsync") .mockImplementation((payload) => { return () => Promise.resolve(payload) as any; }); @@ -620,7 +621,7 @@ describe("calendar Availability search", () => { }; const spy = jest - .spyOn(calendarThunks, "getCalendarDetailAsync") + .spyOn(calendarDetailThunks, "getCalendarDetailAsync") .mockImplementation(() => ({ type: "getCalendarDetailAsync", unwrap: () => Promise.resolve({}), @@ -674,7 +675,7 @@ describe("calendar Availability search", () => { }; const spy = jest - .spyOn(calendarThunks, "getCalendarDetailAsync") + .spyOn(calendarDetailThunks, "getCalendarDetailAsync") .mockImplementation(() => ({ type: "getCalendarDetailAsync", unwrap: () => Promise.resolve({}), @@ -707,7 +708,7 @@ describe("calendar Availability search", () => { it("does not make duplicate API calls for same calendar and range", async () => { const spy = jest - .spyOn(calendarThunks, "getCalendarDetailAsync") + .spyOn(calendarDetailThunks, "getCalendarDetailAsync") .mockImplementation(() => ({ type: "getCalendarDetailAsync", unwrap: () => Promise.resolve({}), diff --git a/__test__/features/Calendars/CalendarSlice.test.tsx b/__test__/features/Calendars/CalendarSlice.test.tsx index 0d9c324..b91cd3a 100644 --- a/__test__/features/Calendars/CalendarSlice.test.tsx +++ b/__test__/features/Calendars/CalendarSlice.test.tsx @@ -4,9 +4,7 @@ import reducer, { createCalendar, updateEventLocal, removeTempCal, - getCalendarsListAsync, getTempCalendarsListAsync, - getCalendarDetailAsync, putEventAsync, getEventAsync, patchCalendarAsync, @@ -17,6 +15,8 @@ import reducer, { addSharedCalendarAsync, deleteEventAsync, } from "../../../src/features/Calendars/CalendarSlice"; +import { getCalendarsListAsync } from "../../../src/features/Calendars/services/getCalendarsListAsync"; +import { getCalendarDetailAsync } from "../../../src/features/Calendars/services/getCalendarDetailAsync"; import * as calAPI from "../../../src/features/Calendars/CalendarApi"; import * as userAPI from "../../../src/features/User/userAPI"; @@ -24,6 +24,8 @@ import * as userAPI from "../../../src/features/User/userAPI"; import { configureStore } from "@reduxjs/toolkit"; import { Calendar } from "../../../src/features/Calendars/CalendarTypes"; import { CalendarEvent } from "../../../src/features/Events/EventsTypes"; +import { setUserData } from "../../../src/features/User/userSlice"; +import userReducer from "../../../src/features/User/userSlice"; jest.mock("../../../src/features/Calendars/CalendarApi"); jest.mock("../../../src/features/User/userAPI"); @@ -36,10 +38,11 @@ describe("CalendarSlice", () => { list: {}, templist: {}, pending: false, + error: null, }; beforeEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); }); describe("reducers", () => { @@ -120,9 +123,11 @@ describe("CalendarSlice", () => { describe("extraReducers (thunks)", () => { const storeFactory = () => configureStore({ - reducer: { calendars: reducer }, + reducer: { calendars: reducer, user: userReducer }, }); - + beforeEach(() => { + jest.resetAllMocks(); + }); it("getCalendarsListAsync.fulfilled replaces list", async () => { (userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" }); (calAPI.getCalendars as jest.Mock).mockResolvedValue({ @@ -270,7 +275,7 @@ describe("CalendarSlice", () => { expect(Object.keys(state.list)).toHaveLength(45); }); - it("getCalendarsListAsync returns early if calendars already exist in store", async () => { + it("getCalendarsListAsync doesnt call getUserDetails if userdata exist in store", async () => { const existingCalendars = { "u1/cal1": { id: "u1/cal1", @@ -284,13 +289,11 @@ describe("CalendarSlice", () => { type: "calendars/getCalendars/fulfilled", payload: { importedCalendars: existingCalendars, errors: "" }, }); - + store.dispatch(setUserData({ openpaasId: "bla" })); 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; @@ -343,7 +346,7 @@ describe("CalendarSlice", () => { ...initialState, list: { c1: { id: calId, events: { e1: { uid: "e1" } } } as any }, }; - const patch = { name: "N", desc: "D", color: "#00f" }; + const patch = { name: "N", desc: "D", color: { "apple:color": "#00f" } }; const state = reducer( prev, patchCalendarAsync.fulfilled( @@ -358,7 +361,7 @@ describe("CalendarSlice", () => { ); expect(state.list[calId].name).toBe("N"); expect(state.list[calId].description).toBe("D"); - expect(state.list[calId].color).toBe("#00f"); + expect(state.list[calId].color?.["apple:color"]).toBe("#00f"); }); it("removeCalendarAsync.fulfilled deletes calendar", () => { @@ -396,7 +399,7 @@ describe("CalendarSlice", () => { const payload = { userId: "u1", calId: "cal1", - color: "#f00", + color: { "apple:color": "#f00" }, name: "Test", desc: "Desc", owner: "Owner", @@ -407,13 +410,13 @@ describe("CalendarSlice", () => { createCalendarAsync.fulfilled(payload, "req4", payload) ); expect(state.list["u1/cal1"].name).toBe("Test"); - expect(state.list["u1/cal1"].color).toBe("#f00"); + expect(state.list["u1/cal1"].color?.["apple:color"]).toBe("#f00"); }); it("addSharedCalendarAsync.fulfilled adds shared calendar", () => { const payload = { calId: "c1", - color: "#0f0", + color: { "apple:color": "#0f0" }, link: "/calendars/u1/c1.json", name: "Shared", desc: "Shared Desc", @@ -466,7 +469,7 @@ describe("CalendarSlice", () => { t1: { id: "t1", name: "Temp", - color: "#aaa", + color: { "apple:color": "#aaa" }, events: {}, visibility: "public", owner: "O", @@ -479,7 +482,7 @@ describe("CalendarSlice", () => { initialState, getTempCalendarsListAsync.fulfilled(payload, "req7", { openpaasId: "u1", - color: "#aaa", + color: { "apple:color": "#aaa" }, displayName: "test", avatarUrl: "", email: "test@test.com", diff --git a/__test__/features/Calendars/services/refreshCalendar.test.tsx b/__test__/features/Calendars/services/refreshCalendar.test.tsx new file mode 100644 index 0000000..f2ee9a2 --- /dev/null +++ b/__test__/features/Calendars/services/refreshCalendar.test.tsx @@ -0,0 +1,674 @@ +import { configureStore } from "@reduxjs/toolkit"; +import reducer from "../../../../src/features/Calendars/CalendarSlice"; +import { + refreshCalendarWithSyncToken, + SyncTokenUpdates, +} from "../../../../src/features/Calendars/services/refreshCalendar"; +import { Calendar } from "../../../../src/features/Calendars/CalendarTypes"; +import { CalendarEvent } from "../../../../src/features/Events/EventsTypes"; +import * as fetchSyncTokenChanges from "../../../../src/features/Calendars/api/fetchSyncTokenChanges"; +import * as EventApi from "../../../../src/features/Events/EventApi"; + +jest.mock("../../../../src/features/Calendars/api/fetchSyncTokenChanges"); +jest.mock("../../../../src/features/Events/EventApi"); + +describe("refreshCalendarWithSyncToken", () => { + const mockCalendar: Calendar = { + id: "user1/cal1", + name: "Test Calendar", + link: "/calendars/user1/cal1.json", + owner: "Test User", + ownerEmails: ["test@example.com"], + description: "Test calendar description", + delegated: false, + color: { light: "#006BD8", dark: "#FFF" }, + visibility: "private", + events: { + event1: { + uid: "event1", + title: "Existing Event", + start: "2024-01-01T10:00:00", + end: "2024-01-01T11:00:00", + calId: "user1/cal1", + URL: "/calendars/user1/cal1/event1.ics", + } as CalendarEvent, + }, + syncToken: "sync-token-123", + }; + + const calendarRange = { + start: new Date("2024-01-01"), + end: new Date("2024-01-31"), + }; + + const storeFactory = () => + configureStore({ + reducer: { calendars: reducer }, + preloadedState: { + calendars: { + list: { [mockCalendar.id]: mockCalendar }, + templist: {}, + pending: false, + error: null, + }, + }, + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("should return early if calendar has no syncToken", async () => { + const calendarWithoutToken = { ...mockCalendar, syncToken: undefined }; + const store = storeFactory(); + + const result = await store.dispatch( + refreshCalendarWithSyncToken({ + calendar: calendarWithoutToken, + calendarRange, + }) + ); + + expect(result.type).toBe("calendars/refreshWithSyncToken/fulfilled"); + expect(result.payload).toEqual({ + calId: calendarWithoutToken.id, + deletedEvents: [], + createdOrUpdatedEvents: [], + calType: undefined, + syncStatus: "NO_SYNC_TOKEN", + }); + expect(fetchSyncTokenChanges.fetchSyncTokenChanges).not.toHaveBeenCalled(); + }); + + it("should handle deleted events (status 404)", async () => { + const mockSyncResponse = { + "sync-token": "new-sync-token-456", + _embedded: { + "dav:item": [ + { + status: 404, + _links: { + self: { href: "/calendars/user1/cal1/deleted-event.ics" }, + }, + }, + ], + }, + }; + + ( + fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock + ).mockResolvedValue(mockSyncResponse); + + const store = storeFactory(); + const result = await store.dispatch( + refreshCalendarWithSyncToken({ + calendar: mockCalendar, + calendarRange, + }) + ); + + expect(result.type).toBe("calendars/refreshWithSyncToken/fulfilled"); + expect(result.payload).toMatchObject({ + calId: mockCalendar.id, + deletedEvents: ["deleted-event"], + createdOrUpdatedEvents: [], + syncToken: "new-sync-token-456", + }); + }); + + it("should handle created/updated events (status 200)", async () => { + const mockSyncResponse = { + "sync-token": "new-sync-token-789", + _embedded: { + "dav:item": [ + { + status: 200, + _links: { + self: { href: "/calendars/user1/cal1/new-event.ics" }, + }, + }, + ], + }, + }; + + const mockEventData = { + data: [ + null, + null, + [ + [ + "vevent", + [ + ["uid", {}, "text", "new-event"], + ["summary", {}, "text", "New Event Title"], + ["dtstart", {}, "date-time", "2024-01-15T10:00:00Z"], + ["dtend", {}, "date-time", "2024-01-15T11:00:00Z"], + ], + [], + ], + ], + ], + _links: { + self: { href: "/calendars/user1/cal1/new-event.ics" }, + }, + }; + + ( + fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock + ).mockResolvedValue(mockSyncResponse); + (EventApi.reportEvent as jest.Mock).mockResolvedValue(mockEventData); + + const store = storeFactory(); + const result = await store.dispatch( + refreshCalendarWithSyncToken({ + calendar: mockCalendar, + calendarRange, + }) + ); + + expect(result.type).toBe("calendars/refreshWithSyncToken/fulfilled"); + if (result.type === "calendars/refreshWithSyncToken/fulfilled") { + const payload = result.payload as SyncTokenUpdates; + expect(payload?.calId).toBe(mockCalendar.id); + expect(payload?.createdOrUpdatedEvents).toHaveLength(1); + expect(payload?.createdOrUpdatedEvents[0].uid).toBe("new-event"); + expect(payload?.syncToken).toBe("new-sync-token-789"); + } + }); + + it("should handle both deletions and updates in same response", async () => { + const mockSyncResponse = { + "sync-token": "new-sync-token-mixed", + _embedded: { + "dav:item": [ + { + status: 404, + _links: { + self: { href: "/calendars/user1/cal1/deleted-event.ics" }, + }, + }, + { + status: 200, + _links: { + self: { href: "/calendars/user1/cal1/updated-event.ics" }, + }, + }, + ], + }, + }; + + const mockEventData = { + data: [ + null, + null, + [ + [ + "vevent", + [ + ["uid", {}, "text", "updated-event"], + ["summary", {}, "text", "Updated Event"], + ["dtstart", {}, "date-time", "2024-01-20T10:00:00Z"], + ["dtend", {}, "date-time", "2024-01-20T11:00:00Z"], + ], + [], + ], + ], + ], + _links: { + self: { href: "/calendars/user1/cal1/updated-event.ics" }, + }, + }; + + ( + fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock + ).mockResolvedValue(mockSyncResponse); + (EventApi.reportEvent as jest.Mock).mockResolvedValue(mockEventData); + + const store = storeFactory(); + const result = await store.dispatch( + refreshCalendarWithSyncToken({ + calendar: mockCalendar, + calendarRange, + }) + ); + + expect(result.type).toBe("calendars/refreshWithSyncToken/fulfilled"); + if (result.type === "calendars/refreshWithSyncToken/fulfilled") { + const payload = result.payload as SyncTokenUpdates; + expect(payload?.deletedEvents).toEqual([ + "deleted-event", + "updated-event", + ]); + expect(payload?.createdOrUpdatedEvents).toHaveLength(1); + expect(payload?.createdOrUpdatedEvents[0].uid).toBe("updated-event"); + } + }); + + it("should reject with SYNC_TOKEN_INVALID error on 410 status", async () => { + const mockSyncResponse = { + _embedded: { + "dav:item": [ + { + status: 410, + _links: { + self: { href: "/calendars/user1/cal1/some-event.ics" }, + }, + }, + ], + }, + }; + + ( + fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock + ).mockResolvedValue(mockSyncResponse); + + const store = storeFactory(); + const result = await store.dispatch( + refreshCalendarWithSyncToken({ + calendar: mockCalendar, + calendarRange, + }) + ); + + expect(result.type).toBe("calendars/refreshWithSyncToken/rejected"); + expect(result.payload).toMatchObject({ + message: expect.stringContaining("SYNC_TOKEN_INVALID"), + }); + }); + + it("should handle API errors gracefully", async () => { + const mockError = new Error("Network error"); + ( + fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock + ).mockRejectedValue(mockError); + + const store = storeFactory(); + const result = await store.dispatch( + refreshCalendarWithSyncToken({ + calendar: mockCalendar, + calendarRange, + }) + ); + + expect(result.type).toBe("calendars/refreshWithSyncToken/rejected"); + expect(result.payload).toMatchObject({ + message: expect.stringContaining("Network error"), + }); + }); + + it("should handle malformed sync response", async () => { + const mockSyncResponse = { + "sync-token": "new-token", + // Missing _embedded field + }; + + ( + fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock + ).mockResolvedValue(mockSyncResponse); + + const store = storeFactory(); + const result = await store.dispatch( + refreshCalendarWithSyncToken({ + calendar: mockCalendar, + calendarRange, + }) + ); + + expect(result.type).toBe("calendars/refreshWithSyncToken/fulfilled"); + expect(result.payload).toMatchObject({ + calId: mockCalendar.id, + deletedEvents: [], + createdOrUpdatedEvents: [], + syncToken: "new-token", + }); + }); + + it("should handle events without href in _links", async () => { + const mockSyncResponse = { + "sync-token": "new-token", + _embedded: { + "dav:item": [ + { + status: 200, + _links: { + self: {}, // Missing href + }, + }, + ], + }, + }; + + ( + fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock + ).mockResolvedValue(mockSyncResponse); + + const store = storeFactory(); + const result = await store.dispatch( + refreshCalendarWithSyncToken({ + calendar: mockCalendar, + calendarRange, + }) + ); + + expect(result.type).toBe("calendars/refreshWithSyncToken/fulfilled"); + if (result.type === "calendars/refreshWithSyncToken/fulfilled") { + const payload = result.payload as SyncTokenUpdates; + expect(payload?.createdOrUpdatedEvents).toHaveLength(0); + } + }); + + it("should continue processing other events if one fails to fetch", async () => { + const mockSyncResponse = { + "sync-token": "new-token", + _embedded: { + "dav:item": [ + { + status: 200, + _links: { + self: { href: "/calendars/user1/cal1/failing-event.ics" }, + }, + }, + { + status: 200, + _links: { + self: { href: "/calendars/user1/cal1/success-event.ics" }, + }, + }, + ], + }, + }; + + const mockEventData = { + data: [ + null, + null, + [ + [ + "vevent", + [ + ["uid", {}, "text", "success-event"], + ["summary", {}, "text", "Success Event"], + ["dtstart", {}, "date-time", "2024-01-20T10:00:00Z"], + ["dtend", {}, "date-time", "2024-01-20T11:00:00Z"], + ], + [], + ], + ], + ], + _links: { + self: { href: "/calendars/user1/cal1/success-event.ics" }, + }, + }; + + ( + fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock + ).mockResolvedValue(mockSyncResponse); + (EventApi.reportEvent as jest.Mock) + .mockRejectedValueOnce(new Error("Failed to fetch")) + .mockResolvedValueOnce(mockEventData); + + const store = storeFactory(); + const result = await store.dispatch( + refreshCalendarWithSyncToken({ + calendar: mockCalendar, + calendarRange, + }) + ); + + expect(result.type).toBe("calendars/refreshWithSyncToken/fulfilled"); + if (result.type === "calendars/refreshWithSyncToken/fulfilled") { + const payload = result.payload as SyncTokenUpdates; + expect(payload?.createdOrUpdatedEvents).toHaveLength(1); + expect(payload?.createdOrUpdatedEvents[0].uid).toBe("success-event"); + } + }); + + it("should update calendar state with new sync token", async () => { + const mockSyncResponse = { + "sync-token": "new-sync-token-state-test", + _embedded: { + "dav:item": [], + }, + }; + + ( + fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock + ).mockResolvedValue(mockSyncResponse); + + const store = storeFactory(); + await store.dispatch( + refreshCalendarWithSyncToken({ + calendar: mockCalendar, + calendarRange, + }) + ); + + const state = store.getState().calendars; + expect(state.list[mockCalendar.id].syncToken).toBe( + "new-sync-token-state-test" + ); + }); + + it("should remove deleted events from calendar state", async () => { + const mockSyncResponse = { + "sync-token": "new-token", + _embedded: { + "dav:item": [ + { + status: 404, + _links: { + self: { href: "/calendars/user1/cal1/event1.ics" }, + }, + }, + ], + }, + }; + + ( + fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock + ).mockResolvedValue(mockSyncResponse); + + const store = storeFactory(); + await store.dispatch( + refreshCalendarWithSyncToken({ + calendar: mockCalendar, + calendarRange, + }) + ); + + const state = store.getState().calendars; + expect(state.list[mockCalendar.id].events["event1"]).toBeUndefined(); + }); + + it("should add new events to calendar state", async () => { + const mockSyncResponse = { + "sync-token": "new-token", + _embedded: { + "dav:item": [ + { + status: 200, + _links: { + self: { href: "/calendars/user1/cal1/new-event.ics" }, + }, + }, + ], + }, + }; + + const mockEventData = { + data: [ + null, + null, + [ + [ + "vevent", + [ + ["uid", {}, "text", "new-event"], + ["summary", {}, "text", "New Event"], + ["dtstart", {}, "date-time", "2024-01-25T10:00:00Z"], + ["dtend", {}, "date-time", "2024-01-25T11:00:00Z"], + ], + [], + ], + ], + ], + _links: { + self: { href: "/calendars/user1/cal1/new-event.ics" }, + }, + }; + + ( + fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock + ).mockResolvedValue(mockSyncResponse); + (EventApi.reportEvent as jest.Mock).mockResolvedValue(mockEventData); + + const store = storeFactory(); + await store.dispatch( + refreshCalendarWithSyncToken({ + calendar: mockCalendar, + calendarRange, + }) + ); + + const state = store.getState().calendars; + expect(state.list[mockCalendar.id].events["new-event"]).toBeDefined(); + expect(state.list[mockCalendar.id].events["new-event"].title).toBe( + "New Event" + ); + }); + + it("should handle recurring events with base UID matching", async () => { + const calendarWithRecurringEvent = { + ...mockCalendar, + events: { + "recurring-base": { + uid: "recurring-base", + title: "Recurring Event", + start: "2024-01-01T10:00:00", + end: "2024-01-01T11:00:00", + calId: "user1/cal1", + URL: "/calendars/user1/cal1/recurring-base.ics", + } as CalendarEvent, + "recurring-base/20240108": { + uid: "recurring-base/20240108", + title: "Recurring Event Instance", + start: "2024-01-08T10:00:00", + end: "2024-01-08T11:00:00", + calId: "user1/cal1", + URL: "/calendars/user1/cal1/recurring-base.ics", + } as CalendarEvent, + }, + }; + + const mockSyncResponse = { + "sync-token": "new-token", + _embedded: { + "dav:item": [ + { + status: 404, + _links: { + self: { href: "/calendars/user1/cal1/recurring-base.ics" }, + }, + }, + ], + }, + }; + + ( + fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock + ).mockResolvedValue(mockSyncResponse); + + const store = configureStore({ + reducer: { calendars: reducer }, + preloadedState: { + calendars: { + list: { [calendarWithRecurringEvent.id]: calendarWithRecurringEvent }, + templist: {}, + pending: false, + error: null, + }, + }, + }); + + await store.dispatch( + refreshCalendarWithSyncToken({ + calendar: calendarWithRecurringEvent, + calendarRange, + }) + ); + + const state = store.getState().calendars; + // Both base event and instance should be removed + expect( + state.list[calendarWithRecurringEvent.id].events["recurring-base"] + ).toBeUndefined(); + expect( + state.list[calendarWithRecurringEvent.id].events[ + "recurring-base/20240108" + ] + ).toBeUndefined(); + }); + + it("should respect maxConcurrency parameter", async () => { + const mockSyncResponse = { + "sync-token": "new-token", + _embedded: { + "dav:item": Array.from({ length: 20 }, (_, i) => ({ + status: 200, + _links: { + self: { href: `/calendars/user1/cal1/event${i}.ics` }, + }, + })), + }, + }; + + const mockEventData = (uid: string) => ({ + data: [ + null, + null, + [ + [ + "vevent", + [ + ["uid", {}, "text", uid], + ["summary", {}, "text", `Event ${uid}`], + ["dtstart", {}, "date-time", "2024-01-20T10:00:00Z"], + ["dtend", {}, "date-time", "2024-01-20T11:00:00Z"], + ], + [], + ], + ], + ], + _links: { + self: { href: `/calendars/user1/cal1/${uid}.ics` }, + }, + }); + + ( + fetchSyncTokenChanges.fetchSyncTokenChanges as jest.Mock + ).mockResolvedValue(mockSyncResponse); + + let concurrentCalls = 0; + let maxConcurrent = 0; + + (EventApi.reportEvent as jest.Mock).mockImplementation(async () => { + concurrentCalls++; + maxConcurrent = Math.max(maxConcurrent, concurrentCalls); + await new Promise((resolve) => setTimeout(resolve, 10)); + concurrentCalls--; + return mockEventData("test-event"); + }); + + const store = storeFactory(); + await store.dispatch( + refreshCalendarWithSyncToken({ + calendar: mockCalendar, + calendarRange, + maxConcurrency: 5, + }) + ); + + // Max concurrent should not exceed 5 + expect(maxConcurrent).toBeLessThanOrEqual(5); + }); +}); diff --git a/__test__/features/user/LoginCallback.test.tsx b/__test__/features/user/LoginCallback.test.tsx index 30ed05b..22980ea 100644 --- a/__test__/features/user/LoginCallback.test.tsx +++ b/__test__/features/user/LoginCallback.test.tsx @@ -1,5 +1,4 @@ // __test__/features/user/CallbackResume.test.tsx -import React from "react"; import { render, waitFor } from "@testing-library/react"; import { CallbackResume } from "../../../src/features/User/LoginCallback"; import { useAppDispatch } from "../../../src/app/hooks"; @@ -10,7 +9,7 @@ import { setUserData, getOpenPaasUserDataAsync, } from "../../../src/features/User/userSlice"; -import { getCalendarsListAsync } from "../../../src/features/Calendars/CalendarSlice"; +import { getCalendarsListAsync } from "../../../src/features/Calendars/services/getCalendarsListAsync"; import { renderWithProviders } from "../../utils/Renderwithproviders"; // Mocks @@ -40,20 +39,23 @@ jest.mock("../../../src/features/User/userSlice", () => { }; }); -jest.mock("../../../src/features/Calendars/CalendarSlice", () => { - const mockGetCalendars = Object.assign( - jest.fn(() => ({ type: "GET_CALENDARS" })), - { - pending: { type: "GET_CALENDARS/pending" }, - fulfilled: { type: "GET_CALENDARS/fulfilled" }, - rejected: { type: "GET_CALENDARS/rejected" }, - } - ); +jest.mock( + "../../../src/features/Calendars/services/getCalendarsListAsync", + () => { + const mockGetCalendars = Object.assign( + jest.fn(() => ({ type: "GET_CALENDARS" })), + { + pending: { type: "GET_CALENDARS/pending" }, + fulfilled: { type: "GET_CALENDARS/fulfilled" }, + rejected: { type: "GET_CALENDARS/rejected" }, + } + ); - return { - getCalendarsListAsync: mockGetCalendars, - }; -}); + return { + getCalendarsListAsync: mockGetCalendars, + }; + } +); describe("CallbackResume", () => { const dispatch = jest.fn(); diff --git a/jest.config.ts b/jest.config.ts index f8c2024..945e1fb 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -35,7 +35,7 @@ const config: Config = { "/fileTransformer.ts", }, transformIgnorePatterns: [ - "/node_modules/(?!(preact|@fullcalendar|react-calendar|get-user-locale|memoize|mimic-function|@wojtekmaj|ky|cozy-ui)/)", + "/node_modules/(?!(preact|@fullcalendar|react-calendar|get-user-locale|memoize|mimic-function|@wojtekmaj|ky|cozy-ui|p-map)/)", ], moduleNameMapper: { diff --git a/package-lock.json b/package-lock.json index 4380626..fa381c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "moment": "^2.30.1", "moment-timezone": "^0.5.48", "openid-client": "^6.5.3", + "p-map": "^7.0.4", "react": "^18.2.0", "react-colorful": "^5.6.1", "react-dom": "^18.2.0", @@ -11746,6 +11747,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", diff --git a/package.json b/package.json index 2e47625..8d88137 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "moment": "^2.30.1", "moment-timezone": "^0.5.48", "openid-client": "^6.5.3", + "p-map": "^7.0.4", "react": "^18.2.0", "react-colorful": "^5.6.1", "react-dom": "^18.2.0", diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index 712fc5d..408f9d8 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -10,7 +10,7 @@ import { useAppDispatch, useAppSelector } from "../../app/hooks"; import EventPopover from "../../features/Events/EventModal"; import { CalendarEvent } from "../../features/Events/EventsTypes"; import CalendarSelection from "./CalendarSelection"; -import { getCalendarDetailAsync } from "../../features/Calendars/CalendarSlice"; +import { getCalendarDetailAsync } from "../../features/Calendars/services/getCalendarDetailAsync"; import ImportAlert from "../../features/Events/ImportAlert"; import { formatDateToYYYYMMDDTHHMMSS, @@ -44,6 +44,7 @@ import viLocale from "@fullcalendar/core/locales/vi"; import SearchResultsPage from "../../features/Search/SearchResultsPage"; import { setTimeZone } from "../../features/Settings/SettingsSlice"; import { browserDefaultTimeZone } from "../../utils/timezone"; +import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid"; const localeMap: Record = { fr: frLocale, @@ -152,7 +153,7 @@ export default function CalendarApp({ setSelectedCalendars(valid); } else { const personalCalendarIds = calendarIds.filter( - (id) => id.split("/")[0] === userId + (id) => extractEventBaseUuid(id) === userId ); setSelectedCalendars(personalCalendarIds); } diff --git a/src/components/Calendar/CalendarLayout.tsx b/src/components/Calendar/CalendarLayout.tsx index 88ecf24..0f3b4df 100644 --- a/src/components/Calendar/CalendarLayout.tsx +++ b/src/components/Calendar/CalendarLayout.tsx @@ -8,7 +8,6 @@ import { refreshCalendars } from "../Event/utils/eventUtils"; import { ErrorSnackbar } from "../Error/ErrorSnackbar"; import SettingsPage from "../../features/Settings/SettingsPage"; -import SearchResultsPage from "../../features/Search/SearchResultsPage"; export default function CalendarLayout() { const calendarRef = useRef(null); diff --git a/src/components/Calendar/CalendarModal.tsx b/src/components/Calendar/CalendarModal.tsx index 35f4e4f..9bd6a34 100644 --- a/src/components/Calendar/CalendarModal.tsx +++ b/src/components/Calendar/CalendarModal.tsx @@ -14,6 +14,7 @@ import { ImportTab } from "./ImportTab"; import { SettingsTab } from "./SettingsTab"; import { defaultColors } from "./utils/calendarColorsUtils"; import { useI18n } from "twake-i18n"; +import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid"; function CalendarPopover({ open, @@ -32,7 +33,9 @@ function CalendarPopover({ const userId = useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; const calendars = useAppSelector((state) => state.calendars.list); - const isOwn = calendar ? calendar?.id.split("/")[0] === userId : true; + const isOwn = calendar?.id + ? extractEventBaseUuid(calendar.id) === userId + : true; // existing calendar params const [name, setName] = useState(""); diff --git a/src/components/Calendar/CalendarSelection.tsx b/src/components/Calendar/CalendarSelection.tsx index cc573b9..4a0fe4e 100644 --- a/src/components/Calendar/CalendarSelection.tsx +++ b/src/components/Calendar/CalendarSelection.tsx @@ -22,6 +22,7 @@ import { removeCalendarAsync } from "../../features/Calendars/CalendarSlice"; import { DeleteCalendarDialog } from "./DeleteCalendarDialog"; import { trimLongTextWithoutSpace } from "../../utils/textUtils"; import { useI18n } from "twake-i18n"; +import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid"; function CalendarAccordion({ title, @@ -112,13 +113,13 @@ export default function CalendarSelection({ const calendars = useAppSelector((state) => state.calendars.list); const personalCalendars = Object.keys(calendars || {}).filter( - (id) => id.split("/")[0] === userId + (id) => extractEventBaseUuid(id) === userId ); const delegatedCalendars = Object.keys(calendars || {}).filter( - (id) => id.split("/")[0] !== userId && calendars[id]?.delegated + (id) => extractEventBaseUuid(id) !== userId && calendars[id]?.delegated ); const sharedCalendars = Object.keys(calendars || {}).filter( - (id) => id.split("/")[0] !== userId && !calendars?.[id]?.delegated + (id) => extractEventBaseUuid(id) !== userId && !calendars?.[id]?.delegated ); const handleCalendarToggle = (name: string) => { diff --git a/src/components/Calendar/ImportTab.tsx b/src/components/Calendar/ImportTab.tsx index 0f32d6a..7bb4272 100644 --- a/src/components/Calendar/ImportTab.tsx +++ b/src/components/Calendar/ImportTab.tsx @@ -15,6 +15,7 @@ import { useAppSelector } from "../../app/hooks"; import { CalendarItemList } from "./CalendarItemList"; import { SettingsTab } from "./SettingsTab"; import { useI18n } from "twake-i18n"; +import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid"; export function ImportTab({ userId, @@ -44,7 +45,7 @@ export function ImportTab({ const [importUrl, setImportUrl] = useState(""); const calendars = useAppSelector((state) => state.calendars.list); const personalCalendars = Object.values(calendars).filter( - (cal) => cal.id.split("/")[0] === userId + (cal) => extractEventBaseUuid(cal.id) === userId ); useEffect(() => { diff --git a/src/components/Calendar/MiniCalendar.tsx b/src/components/Calendar/MiniCalendar.tsx index e9653b7..135909d 100644 --- a/src/components/Calendar/MiniCalendar.tsx +++ b/src/components/Calendar/MiniCalendar.tsx @@ -8,7 +8,7 @@ import { getCalendarRange, } from "../../utils/dateUtils"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; -import { getCalendarDetailAsync } from "../../features/Calendars/CalendarSlice"; +import { getCalendarDetailAsync } from "../../features/Calendars/services/getCalendarDetailAsync"; import { useEffect, useState } from "react"; import { useI18n } from "twake-i18n"; import { setView } from "../../features/Settings/SettingsSlice"; diff --git a/src/components/Calendar/SettingsTab.tsx b/src/components/Calendar/SettingsTab.tsx index 4f321bc..f8455f8 100644 --- a/src/components/Calendar/SettingsTab.tsx +++ b/src/components/Calendar/SettingsTab.tsx @@ -15,6 +15,7 @@ import { useAppSelector } from "../../app/hooks"; import { Calendar } from "../../features/Calendars/CalendarTypes"; import { AddDescButton } from "../Event/AddDescButton"; import { ColorPicker } from "./CalendarColorPicker"; +import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid"; export function SettingsTab({ name, @@ -41,7 +42,7 @@ export function SettingsTab({ const [toggleDesc, setToggleDesc] = useState(Boolean(description)); const userId = useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; - const isOwn = calendar ? calendar.id.split("/")[0] === userId : true; + const isOwn = calendar ? extractEventBaseUuid(calendar.id) === userId : true; useEffect(() => { if (description) setToggleDesc(true); diff --git a/src/components/Calendar/handlers/eventHandlers.ts b/src/components/Calendar/handlers/eventHandlers.ts index c00c83d..15c20ac 100644 --- a/src/components/Calendar/handlers/eventHandlers.ts +++ b/src/components/Calendar/handlers/eventHandlers.ts @@ -4,13 +4,13 @@ import { CalendarEvent } from "../../../features/Events/EventsTypes"; import { Calendar } from "../../../features/Calendars/CalendarTypes"; import { getDeltaInMilliseconds } from "../../../utils/dateUtils"; import { - getCalendarDetailAsync, getEventAsync, putEventAsync, updateEventInstanceAsync, updateEventLocal, updateSeriesAsync, } from "../../../features/Calendars/CalendarSlice"; +import { getCalendarDetailAsync } from "../../../features/Calendars/services/getCalendarDetailAsync"; import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils"; import { getEvent } from "../../../features/Events/EventApi"; import { refreshCalendars } from "../../Event/utils/eventUtils"; diff --git a/src/components/Calendar/utils/calendarUtils.ts b/src/components/Calendar/utils/calendarUtils.ts index 50b12e9..67bb19a 100644 --- a/src/components/Calendar/utils/calendarUtils.ts +++ b/src/components/Calendar/utils/calendarUtils.ts @@ -1,13 +1,14 @@ import { CalendarEvent } from "../../../features/Events/EventsTypes"; import { Calendar } from "../../../features/Calendars/CalendarTypes"; import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils"; -import { getCalendarDetailAsync } from "../../../features/Calendars/CalendarSlice"; +import { getCalendarDetailAsync } from "../../../features/Calendars/services/getCalendarDetailAsync"; import { SlotLabelContentArg } from "@fullcalendar/core"; import moment from "moment-timezone"; import { refreshSingularCalendar } from "../../Event/utils/eventUtils"; import { ThunkDispatch } from "@reduxjs/toolkit"; import { useI18n } from "twake-i18n"; import { detectDateTimeFormat } from "../../Event/utils/dateTimeHelpers"; +import { extractEventBaseUuid } from "../../../utils/extractEventBaseUuid"; function convertEventDateTimeToISO( datetime: string, @@ -102,7 +103,7 @@ export const eventToFullCalendarFormat = ( .map((e) => { const eventTimezone = e.timezone || "Etc/UTC"; const isAllDay = e.allday ?? false; - const isPersonnalEvent = e.calId.split("/")[0] === userId; + const isPersonnalEvent = extractEventBaseUuid(e.calId) === userId; const convertedEvent: any = { ...e, diff --git a/src/components/Event/utils/eventUtils.tsx b/src/components/Event/utils/eventUtils.tsx index f8aface..909cd06 100644 --- a/src/components/Event/utils/eventUtils.tsx +++ b/src/components/Event/utils/eventUtils.tsx @@ -3,14 +3,14 @@ import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import { Avatar, Badge, Box, Typography } from "@linagora/twake-mui"; import { stringToGradient } from "../../../utils/avatarUtils"; import { ThunkDispatch } from "@reduxjs/toolkit"; -import { - emptyEventsCal, - getCalendarDetailAsync, - getCalendarsListAsync, -} from "../../../features/Calendars/CalendarSlice"; +import { emptyEventsCal } from "../../../features/Calendars/CalendarSlice"; +import { getCalendarsListAsync } from "../../../features/Calendars/services/getCalendarsListAsync"; +import { getCalendarDetailAsync } from "../../../features/Calendars/services/getCalendarDetailAsync"; import { Calendar } from "../../../features/Calendars/CalendarTypes"; import { userAttendee } from "../../../features/User/models/attendee"; +import { refreshCalendarWithSyncToken } from "../../../features/Calendars/services/refreshCalendar"; import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils"; +import { AppDispatch } from "../../../app/store"; export function renderAttendeeBadge( a: userAttendee, @@ -108,55 +108,34 @@ export function stringAvatar(name: string) { } export async function refreshCalendars( - dispatch: ThunkDispatch, + dispatch: AppDispatch, calendars: Calendar[], - calendarRange: { start: Date; end: Date }, + calendarRange: { + start: Date; + end: Date; + }, calType?: "temp" ) { - const isTestEnv = process.env.NODE_ENV === "test"; + if (process.env.NODE_ENV === "test") return; - if (!calType && !isTestEnv) { - await dispatch(getCalendarsListAsync()); - } - calType && dispatch(emptyEventsCal({ calType })); + !calType && (await dispatch(getCalendarsListAsync())); - if (isTestEnv) { - return; - } - - const results = await Promise.all( - calendars.map( - async (cal) => - await dispatch( - getCalendarDetailAsync({ - calId: cal.id, - match: { - start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start), - end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end), - }, - calType, - }) - ) + const results = await Promise.allSettled( + calendars.map((calendar) => + dispatch( + refreshCalendarWithSyncToken({ calendar, calType, calendarRange }) + ).unwrap() ) ); - // Check if any result is rejected and throw error - for (const result of results) { - if (result && typeof (result as any).unwrap === "function") { - try { - await (result as any).unwrap(); - } catch (unwrapError: any) { - throw unwrapError; - } - } else if (result.type && (result.type as string).endsWith("/rejected")) { - const rejectedResult = result as any; - throw new Error( - rejectedResult.error?.message || - rejectedResult.payload?.message || - "Failed to refresh calendar" + results.forEach((result, index) => { + if (result.status === "rejected") { + console.error( + `Failed to refresh calendar ${calendars[index].id}:`, + result.reason ); } - } + }); } export async function refreshSingularCalendar( diff --git a/src/components/Menubar/EventSearchBar.tsx b/src/components/Menubar/EventSearchBar.tsx index ab41d5b..6f9dea8 100644 --- a/src/components/Menubar/EventSearchBar.tsx +++ b/src/components/Menubar/EventSearchBar.tsx @@ -29,6 +29,7 @@ import UserSearch from "../Attendees/AttendeeSearch"; import { CalendarItemList } from "../Calendar/CalendarItemList"; import { PeopleSearch, User } from "../Attendees/PeopleSearch"; import { createAttendee } from "../../features/User/models/attendee.mapper"; +import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid"; export default function SearchBar() { const { t } = useI18n(); @@ -38,10 +39,10 @@ export default function SearchBar() { ); const userId = useAppSelector((state) => state.user.userData?.openpaasId); const personnalCalendars = userId - ? calendars.filter((c) => c.id.split("/")[0] === userId) + ? calendars.filter((c) => extractEventBaseUuid(c.id) === userId) : []; const sharedCalendars = userId - ? calendars.filter((c) => c.id.split("/")[0] !== userId) + ? calendars.filter((c) => extractEventBaseUuid(c.id) !== userId) : calendars; const [search, setSearch] = useState(""); diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 069f7fb..b60c02a 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -10,7 +10,7 @@ import { removeCalendar, updateAclCalendar, } from "./CalendarApi"; -import { getOpenPaasUser, getUserDetails } from "../User/userAPI"; +import { getUserDetails } from "../User/userAPI"; import { parseCalendarEvent } from "../Events/eventUtils"; import { deleteEvent, @@ -31,133 +31,17 @@ import { getCalendarVisibility } from "../../components/Calendar/utils/calendarU import { importFile } from "../../utils/apiUtils"; import { formatReduxError } from "../../utils/errorUtils"; import { browserDefaultTimeZone } from "../../utils/timezone"; +import { getCalendarDetailAsync } from "./services/getCalendarDetailAsync"; +import { refreshCalendarWithSyncToken } from "./services/refreshCalendar"; +import { getCalendarsListAsync } from "./services/getCalendarsListAsync"; +import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid"; // Define error type for rejected actions -interface RejectedError { +export interface RejectedError { message: string; status?: number; } -export const getCalendarsListAsync = createAsyncThunk< - { importedCalendars: Record; errors: string }, // Return type - void, // Arg type - { 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; - const calendars = (await getCalendars(user.id)) as Record; - const rawCalendars = calendars._embedded["dav:calendar"] as Record< - string, - any - >[]; - const errors: string[] = []; - - const normalizedCalendars = rawCalendars.map((cal) => { - 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"]); - return { - cal, - description, - delegated, - source, - link, - id, - ownerId, - visibility, - }; - }); - - const uniqueOwnerIds = Array.from( - new Set(normalizedCalendars.map(({ ownerId }) => ownerId).filter(Boolean)) - ); - - const ownerDataMap = new Map(); - const OWNER_BATCH_SIZE = 20; - - const fetchOwnerData = async (ownerId: string) => { - try { - const data = await getUserDetails(ownerId); - ownerDataMap.set(ownerId, data); - } catch (error: any) { - console.error(`Failed to fetch user details for ${ownerId}:`, error); - ownerDataMap.set(ownerId, { - firstname: "", - lastname: "Unknown User", - emails: [], - }); - errors.push(formatReduxError(error)); - } - }; - - for (let i = 0; i < uniqueOwnerIds.length; i += OWNER_BATCH_SIZE) { - const chunk = uniqueOwnerIds.slice(i, i + OWNER_BATCH_SIZE); - await Promise.all(chunk.map((ownerId) => fetchOwnerData(ownerId))); - } - - normalizedCalendars.forEach( - ({ cal, description, delegated, link, id, ownerId, visibility }) => { - const ownerData = ownerDataMap.get(ownerId) || { - firstname: "", - lastname: "Unknown User", - emails: [], - }; - 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, - }); - } -}); - export const getTempCalendarsListAsync = createAsyncThunk< Record, User, @@ -220,51 +104,6 @@ export const getTempCalendarsListAsync = createAsyncThunk< } }); -export const getCalendarDetailAsync = createAsyncThunk< - { calId: string; events: CalendarEvent[]; calType?: string }, - { - calId: string; - match: { start: string; end: string }; - calType?: string; - signal?: AbortSignal; - }, - { rejectValue: RejectedError } ->( - "calendars/getCalendarDetails", - async ({ calId, match, calType, signal }, { rejectWithValue }) => { - try { - const calendar = (await getCalendar(calId, match, signal)) as Record< - string, - any - >; - 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, - }); - } - } -); - export const putEventAsync = createAsyncThunk< { calId: string; events: CalendarEvent[]; calType?: "temp" }, { cal: Calendar; newEvent: CalendarEvent; calType?: "temp" }, @@ -692,9 +531,9 @@ const CalendarSlice = createSlice({ action.payload.event; state.list[action.payload.calendarUid].events[ action.payload.event.uid - ].URL = `/calendars/${action.payload.calendarUid}/${ - action.payload.event.uid.split("/")[0] - }.isc`; + ].URL = `/calendars/${action.payload.calendarUid}/${extractEventBaseUuid( + action.payload.event.uid + )}.ics`; }, removeEvent: ( state, @@ -781,6 +620,7 @@ const CalendarSlice = createSlice({ calId: string; events: CalendarEvent[]; calType?: string; + syncToken?: string; }> ) => { state.pending = false; @@ -789,6 +629,8 @@ const CalendarSlice = createSlice({ if (!state[type][action.payload.calId]) { return; } + state[type][action.payload.calId].syncToken = + action.payload.syncToken; action.payload.events.forEach((event) => { state[type][action.payload.calId].events[event.uid] = event; }); @@ -894,7 +736,7 @@ const CalendarSlice = createSlice({ if (recurrenceId) { Object.keys(state.list[action.payload.calId].events).forEach( (element) => { - if (element.split("/")[0] === baseId) { + if (extractEventBaseUuid(element) === baseId) { delete state.list[action.payload.calId].events[element]; } } @@ -991,6 +833,41 @@ const CalendarSlice = createSlice({ state.pending = false; state.error = null; }) + .addCase(refreshCalendarWithSyncToken.fulfilled, (state, action) => { + state.pending = false; + state.error = null; + + const { + calId, + deletedEvents, + createdOrUpdatedEvents, + calType, + syncToken, + syncStatus, + } = action.payload; + + const target = + calType === "temp" ? state.templist[calId] : state.list[calId]; + + if (!target) { + return; + } + + if (syncStatus === "SUCCESS") { + const deletedSet = new Set(deletedEvents); // working with a Set for deletion avoids O(nxm) complexity + Object.keys(target.events) + .filter((eventKey) => { + const baseUid = extractEventBaseUuid(eventKey); + return deletedSet.has(eventKey) || deletedSet.has(baseUid); + }) + .forEach((eventKey) => delete target.events[eventKey]); + + for (const event of createdOrUpdatedEvents) { + target.events[event.uid] = event; + } + target.syncToken = syncToken; + } + }) // Pending cases .addCase(getCalendarDetailAsync.pending, (state) => { state.pending = true; @@ -1040,6 +917,9 @@ const CalendarSlice = createSlice({ .addCase(importEventFromFileAsync.pending, (state) => { state.pending = true; }) + .addCase(refreshCalendarWithSyncToken.pending, (state) => { + state.pending = true; + }) // Rejected cases .addCase(getCalendarsListAsync.rejected, (state, action) => { if (action.payload?.status !== 401) { @@ -1166,6 +1046,13 @@ const CalendarSlice = createSlice({ action.payload?.message || action.error.message || "Failed to import event from file"; + }) + .addCase(refreshCalendarWithSyncToken.rejected, (state, action) => { + state.pending = false; + state.error = + action.payload?.message || + action.error.message || + "Failed to refresh calendar"; }); }, }); diff --git a/src/features/Calendars/CalendarTypes.ts b/src/features/Calendars/CalendarTypes.ts index 53a2fa4..1290815 100644 --- a/src/features/Calendars/CalendarTypes.ts +++ b/src/features/Calendars/CalendarTypes.ts @@ -15,4 +15,5 @@ export interface Calendar { events: Record; visibility: "private" | "public"; lastCacheCleared?: number; + syncToken?: string; } diff --git a/src/features/Calendars/api/fetchSyncTokenChanges.ts b/src/features/Calendars/api/fetchSyncTokenChanges.ts new file mode 100644 index 0000000..15b984a --- /dev/null +++ b/src/features/Calendars/api/fetchSyncTokenChanges.ts @@ -0,0 +1,19 @@ +import { api } from "../../../utils/apiUtils"; +import { Calendar } from "../CalendarTypes"; +import { DavSyncResponse } from "./types"; + +export async function fetchSyncTokenChanges( + calendar: Calendar +): Promise { + const response = await api(`dav/calendars/${calendar.id}.json`, { + method: "REPORT", + headers: { + Accept: "application/json, text/plain, */*", + }, + body: JSON.stringify({ + "sync-token": calendar.syncToken, + }), + }); + const update: DavSyncResponse = await response.json(); + return update; +} diff --git a/src/features/Calendars/api/types.ts b/src/features/Calendars/api/types.ts new file mode 100644 index 0000000..d41d9dd --- /dev/null +++ b/src/features/Calendars/api/types.ts @@ -0,0 +1,22 @@ +export interface DavSyncItem { + status: number; + _links: CalDavLink; +} + +export type CalDavLink = { + self?: { + href?: string; + }; +}; + +export type CalDavItem = { + data?: unknown[]; + _links?: CalDavLink; +}; + +export interface DavSyncResponse { + _embedded?: { + "dav:item"?: DavSyncItem[]; + }; + "sync-token"?: string; +} diff --git a/src/features/Calendars/services/getCalendarDetailAsync.ts b/src/features/Calendars/services/getCalendarDetailAsync.ts new file mode 100644 index 0000000..1b2c6cc --- /dev/null +++ b/src/features/Calendars/services/getCalendarDetailAsync.ts @@ -0,0 +1,46 @@ +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { formatReduxError } from "../../../utils/errorUtils"; +import { CalendarEvent } from "../../Events/EventsTypes"; +import { getCalendar } from "../CalendarApi"; +import { RejectedError } from "../CalendarSlice"; +import { extractCalendarEvents } from "../utils/extractCalendarEvents"; + +export const getCalendarDetailAsync = createAsyncThunk< + { + calId: string; + events: CalendarEvent[]; + calType?: string; + syncToken?: string; + }, + { + calId: string; + match: { start: string; end: string }; + calType?: string; + signal?: AbortSignal; + }, + { rejectValue: RejectedError } +>( + "calendars/getCalendarDetails", + async ({ calId, match, calType, signal }, { rejectWithValue }) => { + try { + const calendar = (await getCalendar(calId, match, signal)) as any; + + const color = calendar["apple:color"]; + const syncToken = calendar._embedded?.["sync-token"]; + + const items = calendar._embedded?.["dav:item"]; + const events: CalendarEvent[] = Array.isArray(items) + ? items.flatMap((item: any) => + extractCalendarEvents(item, { calId, color }) + ) + : []; + + return { calId, events, calType, syncToken }; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, + }); + } + } +); diff --git a/src/features/Calendars/services/getCalendarsListAsync.ts b/src/features/Calendars/services/getCalendarsListAsync.ts new file mode 100644 index 0000000..d68ac59 --- /dev/null +++ b/src/features/Calendars/services/getCalendarsListAsync.ts @@ -0,0 +1,129 @@ +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { formatReduxError } from "../../../utils/errorUtils"; +import { getOpenPaasUser, getUserDetails } from "../../User/userAPI"; +import { getCalendars } from "../CalendarApi"; +import { RejectedError } from "../CalendarSlice"; +import { Calendar } from "../CalendarTypes"; +import { normalizeCalendar } from "../utils/normalizeCalendar"; + +export const getCalendarsListAsync = createAsyncThunk< + { importedCalendars: Record; errors: string }, + void, + { rejectValue: RejectedError; state: any } +>("calendars/getCalendars", async (_, { rejectWithValue, getState }) => { + const state = getState() as any; + const existingCalendars = state.calendars.list || {}; + const existingUser = { id: state.user?.userData?.openpaasId || undefined }; + try { + const fetchedCalendars: Record = {}; + const user = existingUser.id + ? existingUser + : ((await getOpenPaasUser()) as Record); + const calendars = (await getCalendars(user.id)) as Record; + const rawCalendars = calendars._embedded["dav:calendar"] as Record< + string, + any + >[]; + const errors: string[] = []; + + const normalizedCalendars = rawCalendars.map((cal) => + normalizeCalendar(cal) + ); + + const uniqueOwnerIds = Array.from( + new Set(normalizedCalendars.map(({ ownerId }) => ownerId).filter(Boolean)) + ); + + const ownerDataMap = new Map(); + const OWNER_BATCH_SIZE = 20; + + const fetchOwnerData = async (ownerId: string) => { + try { + const data = await getUserDetails(ownerId); + ownerDataMap.set(ownerId, data); + } catch (error: any) { + console.error(`Failed to fetch user details for ${ownerId}:`, error); + ownerDataMap.set(ownerId, { + firstname: "", + lastname: "Unknown User", + emails: [], + }); + errors.push(formatReduxError(error)); + } + }; + + for (let i = 0; i < uniqueOwnerIds.length; i += OWNER_BATCH_SIZE) { + const chunk = uniqueOwnerIds.slice(i, i + OWNER_BATCH_SIZE); + await Promise.all(chunk.map((ownerId) => fetchOwnerData(ownerId))); + } + + normalizedCalendars.forEach( + ({ cal, description, delegated, link, id, ownerId, visibility }) => { + const ownerData = ownerDataMap.get(ownerId) || { + firstname: "", + lastname: "Unknown User", + emails: [], + }; + 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", + }; + fetchedCalendars[id] = { + id, + name, + link, + owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ownerData.lastname}`, + ownerEmails: ownerData.emails, + description, + delegated, + color, + visibility, + events: {}, + }; + } + ); + + const importedCalendars: Record = {}; + + const fetchedIds = new Set(Object.keys(fetchedCalendars)); + const existingIds = new Set(Object.keys(existingCalendars)); + + const added = [...fetchedIds].filter((id) => !existingIds.has(id)); + + existingIds.forEach((id) => { + if (fetchedIds.has(id)) { + const existingCal = existingCalendars[id]; + const fetchedCal = fetchedCalendars[id]; + + if (fetchedCal) { + importedCalendars[id] = { + ...fetchedCal, + color: existingCal.color, + events: existingCal.events || {}, + }; + } + } + }); + + // Add new calendars + added.forEach((id) => { + importedCalendars[id] = fetchedCalendars[id]; + }); + + return { + importedCalendars, + errors: errors.join("\n"), + }; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, + }); + } +}); diff --git a/src/features/Calendars/services/refreshCalendar.ts b/src/features/Calendars/services/refreshCalendar.ts new file mode 100644 index 0000000..e6ecf2e --- /dev/null +++ b/src/features/Calendars/services/refreshCalendar.ts @@ -0,0 +1,80 @@ +import { createAsyncThunk } from "@reduxjs/toolkit"; +import pMap from "p-map"; +import { formatReduxError } from "../../../utils/errorUtils"; +import { CalendarEvent } from "../../Events/EventsTypes"; +import { fetchSyncTokenChanges } from "../api/fetchSyncTokenChanges"; +import { RejectedError } from "../CalendarSlice"; +import { Calendar } from "../CalendarTypes"; +import { expandEventFunction } from "../utils/expandEventFunction"; +import { processSyncUpdates } from "../utils/processSyncTokenUpdates"; + +export interface SyncTokenUpdates { + calId: string; + deletedEvents: string[]; + createdOrUpdatedEvents: CalendarEvent[]; + calType?: "temp"; + syncToken?: string; + syncStatus?: string; +} + +export const refreshCalendarWithSyncToken = createAsyncThunk< + SyncTokenUpdates, + { + calendar: Calendar; + calType?: "temp"; + calendarRange: { + start: Date; + end: Date; + }; + maxConcurrency?: number; + }, + { + rejectValue: RejectedError; + } +>( + "calendars/refreshWithSyncToken", + async ( + { calendar, maxConcurrency = 8, calendarRange, calType }, + { rejectWithValue } + ) => { + try { + if (!calendar?.syncToken) { + return { + calId: calendar.id, + deletedEvents: [], + createdOrUpdatedEvents: [], + calType, + syncStatus: "NO_SYNC_TOKEN", + }; + } + + const response = await fetchSyncTokenChanges(calendar); + const newSyncToken = response["sync-token"]; + const updates = response?._embedded?.["dav:item"] ?? []; + + const { toDelete, toExpand } = processSyncUpdates(updates); + + const createdOrUpdatedEvents = await pMap( + toExpand, + expandEventFunction(calendarRange, calendar), + { concurrency: maxConcurrency } + ); + + return { + calId: calendar.id, + deletedEvents: toDelete, + createdOrUpdatedEvents: createdOrUpdatedEvents + .flat() + .filter(Boolean) as CalendarEvent[], + calType, + syncToken: newSyncToken, + syncStatus: newSyncToken ? "SUCCESS" : "NO_NEW_SYNC_TOKEN", + }; + } catch (err: any) { + return rejectWithValue({ + message: formatReduxError(err), + status: err.response?.status, + }); + } + } +); diff --git a/src/features/Calendars/utils/expandEventFunction.ts b/src/features/Calendars/utils/expandEventFunction.ts new file mode 100644 index 0000000..45d8092 --- /dev/null +++ b/src/features/Calendars/utils/expandEventFunction.ts @@ -0,0 +1,27 @@ +import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils"; +import { reportEvent } from "../../Events/EventApi"; +import { CalendarEvent } from "../../Events/EventsTypes"; +import { Calendar } from "../CalendarTypes"; +import { extractCalendarEvents } from "./extractCalendarEvents"; + +export function expandEventFunction( + calendarRange: { start: Date; end: Date }, + calendar: Calendar +): (item: string) => Promise { + return async (eventUrl) => { + try { + const item = await reportEvent({ URL: eventUrl } as CalendarEvent, { + start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start), + end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end), + }); + const events: CalendarEvent[] = extractCalendarEvents(item, { + calId: calendar.id, + color: calendar.color, + }); + return events; + } catch (err) { + console.error("Failed to fetch event", eventUrl); + return undefined; + } + }; +} diff --git a/src/features/Calendars/utils/extractCalendarEvents.ts b/src/features/Calendars/utils/extractCalendarEvents.ts new file mode 100644 index 0000000..2d23f6d --- /dev/null +++ b/src/features/Calendars/utils/extractCalendarEvents.ts @@ -0,0 +1,64 @@ +import { defaultColors } from "../../../components/Calendar/utils/calendarColorsUtils"; +import { CalendarEvent } from "../../Events/EventsTypes"; +import { parseCalendarEvent } from "../../Events/eventUtils"; +import { CalDavItem } from "../api/types"; + +export function extractCalendarEvents( + item: CalDavItem, + options: { + calId: string; + color?: Record; + } +): CalendarEvent[] { + const data = item.data; + if (!Array.isArray(data)) { + return []; + } + + // VEVENTS are at index 2 + const vevents = data[2]; + if (!Array.isArray(vevents)) { + return []; + } + + const eventURL = item._links?.self?.href; + if (!eventURL) { + return []; + } + + return vevents + .map((vevent) => { + if (!Array.isArray(vevent)) { + return null; + } + + const eventProps = vevent[1]; + if (!Array.isArray(eventProps)) { + return null; + } + + const valarm = extractValarm(vevent); + + return parseCalendarEvent( + eventProps, + options?.color ?? defaultColors[0], + options.calId, + eventURL, + valarm + ); + }) + .filter(Boolean) as CalendarEvent[]; +} + +function extractValarm(vevent: any[]) { + const subComponents = vevent[2]; + if (!Array.isArray(subComponents)) { + return undefined; + } + + const valarmComponent = subComponents.find( + (component) => Array.isArray(component) && component[0] === "valarm" + ); + + return valarmComponent; +} diff --git a/src/features/Calendars/utils/normalizeCalendar.ts b/src/features/Calendars/utils/normalizeCalendar.ts new file mode 100644 index 0000000..e5ef9ec --- /dev/null +++ b/src/features/Calendars/utils/normalizeCalendar.ts @@ -0,0 +1,27 @@ +import { getCalendarVisibility } from "../../../components/Calendar/utils/calendarUtils"; + +export function normalizeCalendar(rawCalendar: Record) { + const description = rawCalendar["caldav:description"]; + let delegated = false; + let source = rawCalendar["calendarserver:source"] + ? rawCalendar["calendarserver:source"]._links.self.href + : rawCalendar._links.self.href; + const link = rawCalendar._links.self.href; + if (rawCalendar["calendarserver:delegatedsource"]) { + source = rawCalendar["calendarserver:delegatedsource"]; + delegated = true; + } + const id = source.replace("/calendars/", "").replace(".json", ""); + const ownerId = id.split("/")[0]; + const visibility = getCalendarVisibility(rawCalendar["acl"]); + return { + cal: rawCalendar, + description, + delegated, + source, + link, + id, + ownerId, + visibility, + }; +} diff --git a/src/features/Calendars/utils/processSyncTokenUpdates.ts b/src/features/Calendars/utils/processSyncTokenUpdates.ts new file mode 100644 index 0000000..eb21373 --- /dev/null +++ b/src/features/Calendars/utils/processSyncTokenUpdates.ts @@ -0,0 +1,35 @@ +import { DavSyncItem } from "../api/types"; + +export interface ProcessedSyncUpdates { + toDelete: string[]; + toExpand: string[]; +} + +export function processSyncUpdates( + updates: DavSyncItem[] +): ProcessedSyncUpdates { + const toDelete: string[] = []; + const toExpand: string[] = []; + + for (const update of updates) { + const href = update?._links?.self?.href; + if (!href) continue; + const fileName = extractFileNameFromHref(href); + + if (update.status === 404) { + toDelete.push(fileName); + } else if (update.status === 200) { + toExpand.push(href); + toDelete.push(fileName); // we delete the old version of the event to replace it by the new when it's updated + } else if (update.status === 410) { + throw new Error("SYNC_TOKEN_INVALID"); + } + } + + return { toDelete, toExpand }; +} + +function extractFileNameFromHref(href: string): string { + const fileNameMatch = href.match(/\/([^\/]+)\.ics$/); // CalDAV href are like /calendars/userID/CalendarID/EventId.ics + return fileNameMatch ? fileNameMatch[1] : href; +} diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index 25602ff..a0f7909 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -10,7 +10,8 @@ import { import ICAL from "ical.js"; import moment from "moment-timezone"; import { detectDateTimeFormat } from "../../components/Event/utils/dateTimeHelpers"; -import { User } from "../../components/Attendees/PeopleSearch"; +import { CalDavItem } from "../Calendars/api/types"; +import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid"; function resolveTimezoneId(tzid?: string): string | undefined { if (!tzid) return undefined; @@ -23,6 +24,22 @@ function resolveTimezoneId(tzid?: string): string | undefined { return tzid; } +export async function reportEvent( + event: CalendarEvent, + match: { start: string; end: string } +): Promise { + const response = await api(`dav${event.URL}`, { + method: "REPORT", + body: JSON.stringify({ match }), + headers: { Accept: "application/json" }, + }); + if (!response.ok) { + throw new Error(`REPORT request failed with status ${response.status}`); + } + const eventData: CalDavItem = await response.json(); + return eventData; +} + export async function getEvent(event: CalendarEvent, isMaster?: boolean) { const response = await api.get(`dav${event.URL}`); const eventData = await response.text(); @@ -227,7 +244,7 @@ export const deleteEventInstance = async ( const seriesEvent = await getEvent( { ...event, - uid: event.uid.split("/")[0], + uid: extractEventBaseUuid(event.uid), }, true ); diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx index a10a45f..5c43c39 100644 --- a/src/features/Events/EventUpdateModal.tsx +++ b/src/features/Events/EventUpdateModal.tsx @@ -42,6 +42,7 @@ import { EventFormContext, } from "../../utils/eventFormTempStorage"; import { browserDefaultTimeZone } from "../../utils/timezone"; +import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid"; function EventUpdateModal({ eventId, @@ -261,7 +262,7 @@ function EventUpdateModal({ setCalendarid(calId); // Handle repetition properly - check both current event and base event - const baseEventId = event.uid.split("/")[0]; + const baseEventId = extractEventBaseUuid(event.uid); const baseEvent = calendarsList[calId]?.events[baseEventId]; const repetitionSource = event.repetition || baseEvent?.repetition; @@ -478,7 +479,7 @@ function EventUpdateModal({ Object.keys(seriesEvents).forEach((eventId) => { const instance = seriesEvents[eventId]; - if (instance && eventId.split("/")[0] === baseUID) { + if (instance && extractEventBaseUuid(eventId) === baseUID) { instances[eventId] = { ...instance }; } }); @@ -614,7 +615,7 @@ function EventUpdateModal({ event.repetition?.freq && !repetition.freq ) { - const baseUID = event.uid.split("/")[0]; + const baseUID = extractEventBaseUuid(event.uid); // Save current form data to temp storage before closing const formDataToSave = saveCurrentFormData(); @@ -653,7 +654,7 @@ function EventUpdateModal({ // Collect all instances that need to be deleted const instancesToDelete = Object.keys(targetCalendar.events) - .filter((eventId) => eventId.split("/")[0] === baseUID) + .filter((eventId) => extractEventBaseUuid(eventId) === baseUID) .map((eventId) => targetCalendar.events[eventId]); // Get unique URLs to avoid deleting same file multiple times @@ -819,7 +820,7 @@ function EventUpdateModal({ } } else if (typeOfAction === "all") { // Update all instances - check if repetition rules changed - const baseUID = event.uid.split("/")[0]; + const baseUID = extractEventBaseUuid(event.uid); const changes = detectRecurringEventChanges( event, @@ -1074,7 +1075,7 @@ function EventUpdateModal({ moveEventAsync({ cal: targetCalendar, newEvent, - newURL: `/calendars/${newCalId}/${event.uid.split("/")[0]}.ics`, + newURL: `/calendars/${newCalId}/${extractEventBaseUuid(event.uid)}.ics`, }) ); diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index 37f0c8a..a04f076 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -8,6 +8,7 @@ import { detectDateTimeFormat, } from "../../components/Event/utils/dateTimeHelpers"; import { createAttendee } from "../User/models/attendee.mapper"; +import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid"; type RawEntry = [string, Record, string, any]; function resolveTimezoneId(tzid?: string): string | undefined { @@ -189,7 +190,7 @@ export function parseCalendarEvent( } } } - + event.calId = calendarid; event.URL = eventURL; if (!event.uid || !event.start) { console.error( @@ -293,7 +294,7 @@ export function makeVevent( const vevent: any[] = [ "vevent", [ - ["uid", {}, "text", event.uid.split("/")[0]], + ["uid", {}, "text", extractEventBaseUuid(event.uid)], ["transp", {}, "text", event.transp ?? "OPAQUE"], [ "dtstart", diff --git a/src/features/Events/formHelpers.ts b/src/features/Events/formHelpers.ts index 875ac26..3acef53 100644 --- a/src/features/Events/formHelpers.ts +++ b/src/features/Events/formHelpers.ts @@ -5,6 +5,7 @@ import { userAttendee } from "../User/models/attendee"; import { formatDateTimeInTimezone } from "../../components/Event/utils/dateTimeFormatters"; import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils"; import { browserDefaultTimeZone } from "../../utils/timezone"; +import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid"; export interface TimezoneListResult { zones: string[]; @@ -133,7 +134,7 @@ export function populateFormFromEvent( // Handle repetition - check both current event and base event (for update modal) let repetitionSource = event.repetition; if (calendarsList && calId && event.uid) { - const baseEventId = event.uid.split("/")[0]; + const baseEventId = extractEventBaseUuid(event.uid); const baseEvent = calendarsList[calId]?.events[baseEventId]; if (baseEvent?.repetition) { repetitionSource = baseEvent.repetition; diff --git a/src/features/User/HandleLogin.tsx b/src/features/User/HandleLogin.tsx index 77c96dc..26fcdc5 100644 --- a/src/features/User/HandleLogin.tsx +++ b/src/features/User/HandleLogin.tsx @@ -1,11 +1,11 @@ -import React, { useEffect, useRef } from "react"; +import { useEffect, useRef } from "react"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { Auth } from "./oidcAuth"; import { Loading } from "../../components/Loading/Loading"; import { push } from "redux-first-history"; import { redirectTo } from "../../utils/apiUtils"; import { getOpenPaasUserDataAsync, setTokens, setUserData } from "./userSlice"; -import { getCalendarsListAsync } from "../Calendars/CalendarSlice"; +import { getCalendarsListAsync } from "../Calendars/services/getCalendarsListAsync"; export function HandleLogin() { const userData = useAppSelector((state) => state.user); diff --git a/src/features/User/LoginCallback.tsx b/src/features/User/LoginCallback.tsx index 7b20f6a..f3e1df0 100644 --- a/src/features/User/LoginCallback.tsx +++ b/src/features/User/LoginCallback.tsx @@ -9,7 +9,7 @@ import { setUserError, } from "./userSlice"; import { Loading } from "../../components/Loading/Loading"; -import { getCalendarsListAsync } from "../Calendars/CalendarSlice"; +import { getCalendarsListAsync } from "../Calendars/services/getCalendarsListAsync"; export function CallbackResume() { const dispatch = useAppDispatch(); diff --git a/src/utils/extractEventBaseUuid.ts b/src/utils/extractEventBaseUuid.ts new file mode 100644 index 0000000..eb9ff06 --- /dev/null +++ b/src/utils/extractEventBaseUuid.ts @@ -0,0 +1,4 @@ +export function extractEventBaseUuid(eventKey: string) { + if (!eventKey) return ""; + return eventKey.split("/")[0]; +}