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
This commit is contained in:
@@ -3,7 +3,8 @@ import CalendarApp from "../../src/components/Calendar/Calendar";
|
|||||||
import * as eventThunks from "../../src/features/Calendars/CalendarSlice";
|
import * as eventThunks from "../../src/features/Calendars/CalendarSlice";
|
||||||
import { renderWithProviders } from "../utils/Renderwithproviders";
|
import { renderWithProviders } from "../utils/Renderwithproviders";
|
||||||
import { searchUsers } from "../../src/features/User/userAPI";
|
import { searchUsers } from "../../src/features/User/userAPI";
|
||||||
import * as calendarThunks from "../../src/features/Calendars/CalendarSlice";
|
import * as calendarDetailThunks from "../../src/features/Calendars/services/getCalendarDetailAsync";
|
||||||
|
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
|
||||||
import userEvent from "@testing-library/user-event";
|
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 () => {
|
it("should fetch calendar details with date range matching the displayed month", async () => {
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(calendarThunks, "getCalendarDetailAsync")
|
.spyOn(calendarDetailThunks, "getCalendarDetailAsync")
|
||||||
.mockImplementation((payload) => {
|
.mockImplementation((payload) => {
|
||||||
return () => Promise.resolve(payload) as any;
|
return () => Promise.resolve(payload) as any;
|
||||||
});
|
});
|
||||||
@@ -620,7 +621,7 @@ describe("calendar Availability search", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(calendarThunks, "getCalendarDetailAsync")
|
.spyOn(calendarDetailThunks, "getCalendarDetailAsync")
|
||||||
.mockImplementation(() => ({
|
.mockImplementation(() => ({
|
||||||
type: "getCalendarDetailAsync",
|
type: "getCalendarDetailAsync",
|
||||||
unwrap: () => Promise.resolve({}),
|
unwrap: () => Promise.resolve({}),
|
||||||
@@ -674,7 +675,7 @@ describe("calendar Availability search", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(calendarThunks, "getCalendarDetailAsync")
|
.spyOn(calendarDetailThunks, "getCalendarDetailAsync")
|
||||||
.mockImplementation(() => ({
|
.mockImplementation(() => ({
|
||||||
type: "getCalendarDetailAsync",
|
type: "getCalendarDetailAsync",
|
||||||
unwrap: () => Promise.resolve({}),
|
unwrap: () => Promise.resolve({}),
|
||||||
@@ -707,7 +708,7 @@ describe("calendar Availability search", () => {
|
|||||||
|
|
||||||
it("does not make duplicate API calls for same calendar and range", async () => {
|
it("does not make duplicate API calls for same calendar and range", async () => {
|
||||||
const spy = jest
|
const spy = jest
|
||||||
.spyOn(calendarThunks, "getCalendarDetailAsync")
|
.spyOn(calendarDetailThunks, "getCalendarDetailAsync")
|
||||||
.mockImplementation(() => ({
|
.mockImplementation(() => ({
|
||||||
type: "getCalendarDetailAsync",
|
type: "getCalendarDetailAsync",
|
||||||
unwrap: () => Promise.resolve({}),
|
unwrap: () => Promise.resolve({}),
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import reducer, {
|
|||||||
createCalendar,
|
createCalendar,
|
||||||
updateEventLocal,
|
updateEventLocal,
|
||||||
removeTempCal,
|
removeTempCal,
|
||||||
getCalendarsListAsync,
|
|
||||||
getTempCalendarsListAsync,
|
getTempCalendarsListAsync,
|
||||||
getCalendarDetailAsync,
|
|
||||||
putEventAsync,
|
putEventAsync,
|
||||||
getEventAsync,
|
getEventAsync,
|
||||||
patchCalendarAsync,
|
patchCalendarAsync,
|
||||||
@@ -17,6 +15,8 @@ import reducer, {
|
|||||||
addSharedCalendarAsync,
|
addSharedCalendarAsync,
|
||||||
deleteEventAsync,
|
deleteEventAsync,
|
||||||
} from "../../../src/features/Calendars/CalendarSlice";
|
} 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 calAPI from "../../../src/features/Calendars/CalendarApi";
|
||||||
import * as userAPI from "../../../src/features/User/userAPI";
|
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 { configureStore } from "@reduxjs/toolkit";
|
||||||
import { Calendar } from "../../../src/features/Calendars/CalendarTypes";
|
import { Calendar } from "../../../src/features/Calendars/CalendarTypes";
|
||||||
import { CalendarEvent } from "../../../src/features/Events/EventsTypes";
|
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/Calendars/CalendarApi");
|
||||||
jest.mock("../../../src/features/User/userAPI");
|
jest.mock("../../../src/features/User/userAPI");
|
||||||
@@ -36,10 +38,11 @@ describe("CalendarSlice", () => {
|
|||||||
list: {},
|
list: {},
|
||||||
templist: {},
|
templist: {},
|
||||||
pending: false,
|
pending: false,
|
||||||
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("reducers", () => {
|
describe("reducers", () => {
|
||||||
@@ -120,9 +123,11 @@ describe("CalendarSlice", () => {
|
|||||||
describe("extraReducers (thunks)", () => {
|
describe("extraReducers (thunks)", () => {
|
||||||
const storeFactory = () =>
|
const storeFactory = () =>
|
||||||
configureStore({
|
configureStore({
|
||||||
reducer: { calendars: reducer },
|
reducer: { calendars: reducer, user: userReducer },
|
||||||
});
|
});
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
it("getCalendarsListAsync.fulfilled replaces list", async () => {
|
it("getCalendarsListAsync.fulfilled replaces list", async () => {
|
||||||
(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" });
|
(userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" });
|
||||||
(calAPI.getCalendars as jest.Mock).mockResolvedValue({
|
(calAPI.getCalendars as jest.Mock).mockResolvedValue({
|
||||||
@@ -270,7 +275,7 @@ describe("CalendarSlice", () => {
|
|||||||
expect(Object.keys(state.list)).toHaveLength(45);
|
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 = {
|
const existingCalendars = {
|
||||||
"u1/cal1": {
|
"u1/cal1": {
|
||||||
id: "u1/cal1",
|
id: "u1/cal1",
|
||||||
@@ -284,13 +289,11 @@ describe("CalendarSlice", () => {
|
|||||||
type: "calendars/getCalendars/fulfilled",
|
type: "calendars/getCalendars/fulfilled",
|
||||||
payload: { importedCalendars: existingCalendars, errors: "" },
|
payload: { importedCalendars: existingCalendars, errors: "" },
|
||||||
});
|
});
|
||||||
|
store.dispatch(setUserData({ openpaasId: "bla" }));
|
||||||
const getUserDetailsMock = userAPI.getUserDetails as jest.Mock;
|
const getUserDetailsMock = userAPI.getUserDetails as jest.Mock;
|
||||||
const getCalendarsMock = calAPI.getCalendars as jest.Mock;
|
|
||||||
|
|
||||||
await store.dispatch(getCalendarsListAsync() as any);
|
await store.dispatch(getCalendarsListAsync() as any);
|
||||||
|
|
||||||
expect(getCalendarsMock).not.toHaveBeenCalled();
|
|
||||||
expect(getUserDetailsMock).not.toHaveBeenCalled();
|
expect(getUserDetailsMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const state = store.getState().calendars;
|
const state = store.getState().calendars;
|
||||||
@@ -343,7 +346,7 @@ describe("CalendarSlice", () => {
|
|||||||
...initialState,
|
...initialState,
|
||||||
list: { c1: { id: calId, events: { e1: { uid: "e1" } } } as any },
|
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(
|
const state = reducer(
|
||||||
prev,
|
prev,
|
||||||
patchCalendarAsync.fulfilled(
|
patchCalendarAsync.fulfilled(
|
||||||
@@ -358,7 +361,7 @@ describe("CalendarSlice", () => {
|
|||||||
);
|
);
|
||||||
expect(state.list[calId].name).toBe("N");
|
expect(state.list[calId].name).toBe("N");
|
||||||
expect(state.list[calId].description).toBe("D");
|
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", () => {
|
it("removeCalendarAsync.fulfilled deletes calendar", () => {
|
||||||
@@ -396,7 +399,7 @@ describe("CalendarSlice", () => {
|
|||||||
const payload = {
|
const payload = {
|
||||||
userId: "u1",
|
userId: "u1",
|
||||||
calId: "cal1",
|
calId: "cal1",
|
||||||
color: "#f00",
|
color: { "apple:color": "#f00" },
|
||||||
name: "Test",
|
name: "Test",
|
||||||
desc: "Desc",
|
desc: "Desc",
|
||||||
owner: "Owner",
|
owner: "Owner",
|
||||||
@@ -407,13 +410,13 @@ describe("CalendarSlice", () => {
|
|||||||
createCalendarAsync.fulfilled(payload, "req4", payload)
|
createCalendarAsync.fulfilled(payload, "req4", payload)
|
||||||
);
|
);
|
||||||
expect(state.list["u1/cal1"].name).toBe("Test");
|
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", () => {
|
it("addSharedCalendarAsync.fulfilled adds shared calendar", () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
calId: "c1",
|
calId: "c1",
|
||||||
color: "#0f0",
|
color: { "apple:color": "#0f0" },
|
||||||
link: "/calendars/u1/c1.json",
|
link: "/calendars/u1/c1.json",
|
||||||
name: "Shared",
|
name: "Shared",
|
||||||
desc: "Shared Desc",
|
desc: "Shared Desc",
|
||||||
@@ -466,7 +469,7 @@ describe("CalendarSlice", () => {
|
|||||||
t1: {
|
t1: {
|
||||||
id: "t1",
|
id: "t1",
|
||||||
name: "Temp",
|
name: "Temp",
|
||||||
color: "#aaa",
|
color: { "apple:color": "#aaa" },
|
||||||
events: {},
|
events: {},
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
owner: "O",
|
owner: "O",
|
||||||
@@ -479,7 +482,7 @@ describe("CalendarSlice", () => {
|
|||||||
initialState,
|
initialState,
|
||||||
getTempCalendarsListAsync.fulfilled(payload, "req7", {
|
getTempCalendarsListAsync.fulfilled(payload, "req7", {
|
||||||
openpaasId: "u1",
|
openpaasId: "u1",
|
||||||
color: "#aaa",
|
color: { "apple:color": "#aaa" },
|
||||||
displayName: "test",
|
displayName: "test",
|
||||||
avatarUrl: "",
|
avatarUrl: "",
|
||||||
email: "test@test.com",
|
email: "test@test.com",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
// __test__/features/user/CallbackResume.test.tsx
|
// __test__/features/user/CallbackResume.test.tsx
|
||||||
import React from "react";
|
|
||||||
import { render, waitFor } from "@testing-library/react";
|
import { render, waitFor } from "@testing-library/react";
|
||||||
import { CallbackResume } from "../../../src/features/User/LoginCallback";
|
import { CallbackResume } from "../../../src/features/User/LoginCallback";
|
||||||
import { useAppDispatch } from "../../../src/app/hooks";
|
import { useAppDispatch } from "../../../src/app/hooks";
|
||||||
@@ -10,7 +9,7 @@ import {
|
|||||||
setUserData,
|
setUserData,
|
||||||
getOpenPaasUserDataAsync,
|
getOpenPaasUserDataAsync,
|
||||||
} from "../../../src/features/User/userSlice";
|
} 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";
|
import { renderWithProviders } from "../../utils/Renderwithproviders";
|
||||||
|
|
||||||
// Mocks
|
// Mocks
|
||||||
@@ -40,20 +39,23 @@ jest.mock("../../../src/features/User/userSlice", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.mock("../../../src/features/Calendars/CalendarSlice", () => {
|
jest.mock(
|
||||||
const mockGetCalendars = Object.assign(
|
"../../../src/features/Calendars/services/getCalendarsListAsync",
|
||||||
jest.fn(() => ({ type: "GET_CALENDARS" })),
|
() => {
|
||||||
{
|
const mockGetCalendars = Object.assign(
|
||||||
pending: { type: "GET_CALENDARS/pending" },
|
jest.fn(() => ({ type: "GET_CALENDARS" })),
|
||||||
fulfilled: { type: "GET_CALENDARS/fulfilled" },
|
{
|
||||||
rejected: { type: "GET_CALENDARS/rejected" },
|
pending: { type: "GET_CALENDARS/pending" },
|
||||||
}
|
fulfilled: { type: "GET_CALENDARS/fulfilled" },
|
||||||
);
|
rejected: { type: "GET_CALENDARS/rejected" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getCalendarsListAsync: mockGetCalendars,
|
getCalendarsListAsync: mockGetCalendars,
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
describe("CallbackResume", () => {
|
describe("CallbackResume", () => {
|
||||||
const dispatch = jest.fn();
|
const dispatch = jest.fn();
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ const config: Config = {
|
|||||||
"<rootDir>/fileTransformer.ts",
|
"<rootDir>/fileTransformer.ts",
|
||||||
},
|
},
|
||||||
transformIgnorePatterns: [
|
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: {
|
moduleNameMapper: {
|
||||||
|
|||||||
Generated
+13
@@ -29,6 +29,7 @@
|
|||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"moment-timezone": "^0.5.48",
|
"moment-timezone": "^0.5.48",
|
||||||
"openid-client": "^6.5.3",
|
"openid-client": "^6.5.3",
|
||||||
|
"p-map": "^7.0.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -11746,6 +11747,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/p-try": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"moment-timezone": "^0.5.48",
|
"moment-timezone": "^0.5.48",
|
||||||
"openid-client": "^6.5.3",
|
"openid-client": "^6.5.3",
|
||||||
|
"p-map": "^7.0.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ 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";
|
||||||
import CalendarSelection from "./CalendarSelection";
|
import CalendarSelection from "./CalendarSelection";
|
||||||
import { getCalendarDetailAsync } from "../../features/Calendars/CalendarSlice";
|
import { getCalendarDetailAsync } from "../../features/Calendars/services/getCalendarDetailAsync";
|
||||||
import ImportAlert from "../../features/Events/ImportAlert";
|
import ImportAlert from "../../features/Events/ImportAlert";
|
||||||
import {
|
import {
|
||||||
formatDateToYYYYMMDDTHHMMSS,
|
formatDateToYYYYMMDDTHHMMSS,
|
||||||
@@ -44,6 +44,7 @@ import viLocale from "@fullcalendar/core/locales/vi";
|
|||||||
import SearchResultsPage from "../../features/Search/SearchResultsPage";
|
import SearchResultsPage from "../../features/Search/SearchResultsPage";
|
||||||
import { setTimeZone } from "../../features/Settings/SettingsSlice";
|
import { setTimeZone } from "../../features/Settings/SettingsSlice";
|
||||||
import { browserDefaultTimeZone } from "../../utils/timezone";
|
import { browserDefaultTimeZone } from "../../utils/timezone";
|
||||||
|
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
||||||
|
|
||||||
const localeMap: Record<string, any> = {
|
const localeMap: Record<string, any> = {
|
||||||
fr: frLocale,
|
fr: frLocale,
|
||||||
@@ -152,7 +153,7 @@ export default function CalendarApp({
|
|||||||
setSelectedCalendars(valid);
|
setSelectedCalendars(valid);
|
||||||
} else {
|
} else {
|
||||||
const personalCalendarIds = calendarIds.filter(
|
const personalCalendarIds = calendarIds.filter(
|
||||||
(id) => id.split("/")[0] === userId
|
(id) => extractEventBaseUuid(id) === userId
|
||||||
);
|
);
|
||||||
setSelectedCalendars(personalCalendarIds);
|
setSelectedCalendars(personalCalendarIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { refreshCalendars } from "../Event/utils/eventUtils";
|
|||||||
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
|
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
|
||||||
|
|
||||||
import SettingsPage from "../../features/Settings/SettingsPage";
|
import SettingsPage from "../../features/Settings/SettingsPage";
|
||||||
import SearchResultsPage from "../../features/Search/SearchResultsPage";
|
|
||||||
|
|
||||||
export default function CalendarLayout() {
|
export default function CalendarLayout() {
|
||||||
const calendarRef = useRef<any>(null);
|
const calendarRef = useRef<any>(null);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ImportTab } from "./ImportTab";
|
|||||||
import { SettingsTab } from "./SettingsTab";
|
import { SettingsTab } from "./SettingsTab";
|
||||||
import { defaultColors } from "./utils/calendarColorsUtils";
|
import { defaultColors } from "./utils/calendarColorsUtils";
|
||||||
import { useI18n } from "twake-i18n";
|
import { useI18n } from "twake-i18n";
|
||||||
|
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
||||||
|
|
||||||
function CalendarPopover({
|
function CalendarPopover({
|
||||||
open,
|
open,
|
||||||
@@ -32,7 +33,9 @@ function CalendarPopover({
|
|||||||
const userId =
|
const userId =
|
||||||
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 isOwn = calendar ? calendar?.id.split("/")[0] === userId : true;
|
const isOwn = calendar?.id
|
||||||
|
? extractEventBaseUuid(calendar.id) === userId
|
||||||
|
: true;
|
||||||
|
|
||||||
// existing calendar params
|
// existing calendar params
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { removeCalendarAsync } from "../../features/Calendars/CalendarSlice";
|
|||||||
import { DeleteCalendarDialog } from "./DeleteCalendarDialog";
|
import { DeleteCalendarDialog } from "./DeleteCalendarDialog";
|
||||||
import { trimLongTextWithoutSpace } from "../../utils/textUtils";
|
import { trimLongTextWithoutSpace } from "../../utils/textUtils";
|
||||||
import { useI18n } from "twake-i18n";
|
import { useI18n } from "twake-i18n";
|
||||||
|
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
||||||
|
|
||||||
function CalendarAccordion({
|
function CalendarAccordion({
|
||||||
title,
|
title,
|
||||||
@@ -112,13 +113,13 @@ export default function CalendarSelection({
|
|||||||
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) => extractEventBaseUuid(id) === userId
|
||||||
);
|
);
|
||||||
const delegatedCalendars = Object.keys(calendars || {}).filter(
|
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(
|
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) => {
|
const handleCalendarToggle = (name: string) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useAppSelector } from "../../app/hooks";
|
|||||||
import { CalendarItemList } from "./CalendarItemList";
|
import { CalendarItemList } from "./CalendarItemList";
|
||||||
import { SettingsTab } from "./SettingsTab";
|
import { SettingsTab } from "./SettingsTab";
|
||||||
import { useI18n } from "twake-i18n";
|
import { useI18n } from "twake-i18n";
|
||||||
|
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
||||||
|
|
||||||
export function ImportTab({
|
export function ImportTab({
|
||||||
userId,
|
userId,
|
||||||
@@ -44,7 +45,7 @@ export function ImportTab({
|
|||||||
const [importUrl, setImportUrl] = useState("");
|
const [importUrl, setImportUrl] = useState("");
|
||||||
const calendars = useAppSelector((state) => state.calendars.list);
|
const calendars = useAppSelector((state) => state.calendars.list);
|
||||||
const personalCalendars = Object.values(calendars).filter(
|
const personalCalendars = Object.values(calendars).filter(
|
||||||
(cal) => cal.id.split("/")[0] === userId
|
(cal) => extractEventBaseUuid(cal.id) === userId
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
getCalendarRange,
|
getCalendarRange,
|
||||||
} from "../../utils/dateUtils";
|
} from "../../utils/dateUtils";
|
||||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
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 { useEffect, useState } from "react";
|
||||||
import { useI18n } from "twake-i18n";
|
import { useI18n } from "twake-i18n";
|
||||||
import { setView } from "../../features/Settings/SettingsSlice";
|
import { setView } from "../../features/Settings/SettingsSlice";
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useAppSelector } from "../../app/hooks";
|
|||||||
import { Calendar } from "../../features/Calendars/CalendarTypes";
|
import { Calendar } from "../../features/Calendars/CalendarTypes";
|
||||||
import { AddDescButton } from "../Event/AddDescButton";
|
import { AddDescButton } from "../Event/AddDescButton";
|
||||||
import { ColorPicker } from "./CalendarColorPicker";
|
import { ColorPicker } from "./CalendarColorPicker";
|
||||||
|
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
||||||
|
|
||||||
export function SettingsTab({
|
export function SettingsTab({
|
||||||
name,
|
name,
|
||||||
@@ -41,7 +42,7 @@ export function SettingsTab({
|
|||||||
const [toggleDesc, setToggleDesc] = useState(Boolean(description));
|
const [toggleDesc, setToggleDesc] = useState(Boolean(description));
|
||||||
const userId =
|
const userId =
|
||||||
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
|
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
|
||||||
const isOwn = calendar ? calendar.id.split("/")[0] === userId : true;
|
const isOwn = calendar ? extractEventBaseUuid(calendar.id) === userId : true;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (description) setToggleDesc(true);
|
if (description) setToggleDesc(true);
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { CalendarEvent } from "../../../features/Events/EventsTypes";
|
|||||||
import { Calendar } from "../../../features/Calendars/CalendarTypes";
|
import { Calendar } from "../../../features/Calendars/CalendarTypes";
|
||||||
import { getDeltaInMilliseconds } from "../../../utils/dateUtils";
|
import { getDeltaInMilliseconds } from "../../../utils/dateUtils";
|
||||||
import {
|
import {
|
||||||
getCalendarDetailAsync,
|
|
||||||
getEventAsync,
|
getEventAsync,
|
||||||
putEventAsync,
|
putEventAsync,
|
||||||
updateEventInstanceAsync,
|
updateEventInstanceAsync,
|
||||||
updateEventLocal,
|
updateEventLocal,
|
||||||
updateSeriesAsync,
|
updateSeriesAsync,
|
||||||
} from "../../../features/Calendars/CalendarSlice";
|
} from "../../../features/Calendars/CalendarSlice";
|
||||||
|
import { getCalendarDetailAsync } from "../../../features/Calendars/services/getCalendarDetailAsync";
|
||||||
import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils";
|
import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils";
|
||||||
import { getEvent } from "../../../features/Events/EventApi";
|
import { getEvent } from "../../../features/Events/EventApi";
|
||||||
import { refreshCalendars } from "../../Event/utils/eventUtils";
|
import { refreshCalendars } from "../../Event/utils/eventUtils";
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { CalendarEvent } from "../../../features/Events/EventsTypes";
|
import { CalendarEvent } from "../../../features/Events/EventsTypes";
|
||||||
import { Calendar } from "../../../features/Calendars/CalendarTypes";
|
import { Calendar } from "../../../features/Calendars/CalendarTypes";
|
||||||
import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils";
|
import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils";
|
||||||
import { getCalendarDetailAsync } from "../../../features/Calendars/CalendarSlice";
|
import { getCalendarDetailAsync } from "../../../features/Calendars/services/getCalendarDetailAsync";
|
||||||
import { SlotLabelContentArg } from "@fullcalendar/core";
|
import { SlotLabelContentArg } from "@fullcalendar/core";
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
import { refreshSingularCalendar } from "../../Event/utils/eventUtils";
|
import { refreshSingularCalendar } from "../../Event/utils/eventUtils";
|
||||||
import { ThunkDispatch } from "@reduxjs/toolkit";
|
import { ThunkDispatch } from "@reduxjs/toolkit";
|
||||||
import { useI18n } from "twake-i18n";
|
import { useI18n } from "twake-i18n";
|
||||||
import { detectDateTimeFormat } from "../../Event/utils/dateTimeHelpers";
|
import { detectDateTimeFormat } from "../../Event/utils/dateTimeHelpers";
|
||||||
|
import { extractEventBaseUuid } from "../../../utils/extractEventBaseUuid";
|
||||||
|
|
||||||
function convertEventDateTimeToISO(
|
function convertEventDateTimeToISO(
|
||||||
datetime: string,
|
datetime: string,
|
||||||
@@ -102,7 +103,7 @@ export const eventToFullCalendarFormat = (
|
|||||||
.map((e) => {
|
.map((e) => {
|
||||||
const eventTimezone = e.timezone || "Etc/UTC";
|
const eventTimezone = e.timezone || "Etc/UTC";
|
||||||
const isAllDay = e.allday ?? false;
|
const isAllDay = e.allday ?? false;
|
||||||
const isPersonnalEvent = e.calId.split("/")[0] === userId;
|
const isPersonnalEvent = extractEventBaseUuid(e.calId) === userId;
|
||||||
|
|
||||||
const convertedEvent: any = {
|
const convertedEvent: any = {
|
||||||
...e,
|
...e,
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
|||||||
import { Avatar, Badge, Box, Typography } from "@linagora/twake-mui";
|
import { Avatar, Badge, Box, Typography } from "@linagora/twake-mui";
|
||||||
import { stringToGradient } from "../../../utils/avatarUtils";
|
import { stringToGradient } from "../../../utils/avatarUtils";
|
||||||
import { ThunkDispatch } from "@reduxjs/toolkit";
|
import { ThunkDispatch } from "@reduxjs/toolkit";
|
||||||
import {
|
import { emptyEventsCal } from "../../../features/Calendars/CalendarSlice";
|
||||||
emptyEventsCal,
|
import { getCalendarsListAsync } from "../../../features/Calendars/services/getCalendarsListAsync";
|
||||||
getCalendarDetailAsync,
|
import { getCalendarDetailAsync } from "../../../features/Calendars/services/getCalendarDetailAsync";
|
||||||
getCalendarsListAsync,
|
|
||||||
} from "../../../features/Calendars/CalendarSlice";
|
|
||||||
import { Calendar } from "../../../features/Calendars/CalendarTypes";
|
import { Calendar } from "../../../features/Calendars/CalendarTypes";
|
||||||
import { userAttendee } from "../../../features/User/models/attendee";
|
import { userAttendee } from "../../../features/User/models/attendee";
|
||||||
|
import { refreshCalendarWithSyncToken } from "../../../features/Calendars/services/refreshCalendar";
|
||||||
import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils";
|
import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils";
|
||||||
|
import { AppDispatch } from "../../../app/store";
|
||||||
|
|
||||||
export function renderAttendeeBadge(
|
export function renderAttendeeBadge(
|
||||||
a: userAttendee,
|
a: userAttendee,
|
||||||
@@ -108,55 +108,34 @@ export function stringAvatar(name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshCalendars(
|
export async function refreshCalendars(
|
||||||
dispatch: ThunkDispatch<any, any, any>,
|
dispatch: AppDispatch,
|
||||||
calendars: Calendar[],
|
calendars: Calendar[],
|
||||||
calendarRange: { start: Date; end: Date },
|
calendarRange: {
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
},
|
||||||
calType?: "temp"
|
calType?: "temp"
|
||||||
) {
|
) {
|
||||||
const isTestEnv = process.env.NODE_ENV === "test";
|
if (process.env.NODE_ENV === "test") return;
|
||||||
|
|
||||||
if (!calType && !isTestEnv) {
|
!calType && (await dispatch(getCalendarsListAsync()));
|
||||||
await dispatch(getCalendarsListAsync());
|
|
||||||
}
|
|
||||||
calType && dispatch(emptyEventsCal({ calType }));
|
|
||||||
|
|
||||||
if (isTestEnv) {
|
const results = await Promise.allSettled(
|
||||||
return;
|
calendars.map((calendar) =>
|
||||||
}
|
dispatch(
|
||||||
|
refreshCalendarWithSyncToken({ calendar, calType, calendarRange })
|
||||||
const results = await Promise.all(
|
).unwrap()
|
||||||
calendars.map(
|
|
||||||
async (cal) =>
|
|
||||||
await dispatch(
|
|
||||||
getCalendarDetailAsync({
|
|
||||||
calId: cal.id,
|
|
||||||
match: {
|
|
||||||
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
|
|
||||||
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
|
|
||||||
},
|
|
||||||
calType,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if any result is rejected and throw error
|
results.forEach((result, index) => {
|
||||||
for (const result of results) {
|
if (result.status === "rejected") {
|
||||||
if (result && typeof (result as any).unwrap === "function") {
|
console.error(
|
||||||
try {
|
`Failed to refresh calendar ${calendars[index].id}:`,
|
||||||
await (result as any).unwrap();
|
result.reason
|
||||||
} 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"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshSingularCalendar(
|
export async function refreshSingularCalendar(
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import UserSearch from "../Attendees/AttendeeSearch";
|
|||||||
import { CalendarItemList } from "../Calendar/CalendarItemList";
|
import { CalendarItemList } from "../Calendar/CalendarItemList";
|
||||||
import { PeopleSearch, User } from "../Attendees/PeopleSearch";
|
import { PeopleSearch, User } from "../Attendees/PeopleSearch";
|
||||||
import { createAttendee } from "../../features/User/models/attendee.mapper";
|
import { createAttendee } from "../../features/User/models/attendee.mapper";
|
||||||
|
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
||||||
|
|
||||||
export default function SearchBar() {
|
export default function SearchBar() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -38,10 +39,10 @@ export default function SearchBar() {
|
|||||||
);
|
);
|
||||||
const userId = useAppSelector((state) => state.user.userData?.openpaasId);
|
const userId = useAppSelector((state) => state.user.userData?.openpaasId);
|
||||||
const personnalCalendars = userId
|
const personnalCalendars = userId
|
||||||
? calendars.filter((c) => c.id.split("/")[0] === userId)
|
? calendars.filter((c) => extractEventBaseUuid(c.id) === userId)
|
||||||
: [];
|
: [];
|
||||||
const sharedCalendars = userId
|
const sharedCalendars = userId
|
||||||
? calendars.filter((c) => c.id.split("/")[0] !== userId)
|
? calendars.filter((c) => extractEventBaseUuid(c.id) !== userId)
|
||||||
: calendars;
|
: calendars;
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
removeCalendar,
|
removeCalendar,
|
||||||
updateAclCalendar,
|
updateAclCalendar,
|
||||||
} from "./CalendarApi";
|
} from "./CalendarApi";
|
||||||
import { getOpenPaasUser, getUserDetails } from "../User/userAPI";
|
import { getUserDetails } from "../User/userAPI";
|
||||||
import { parseCalendarEvent } from "../Events/eventUtils";
|
import { parseCalendarEvent } from "../Events/eventUtils";
|
||||||
import {
|
import {
|
||||||
deleteEvent,
|
deleteEvent,
|
||||||
@@ -31,133 +31,17 @@ import { getCalendarVisibility } from "../../components/Calendar/utils/calendarU
|
|||||||
import { importFile } from "../../utils/apiUtils";
|
import { importFile } from "../../utils/apiUtils";
|
||||||
import { formatReduxError } from "../../utils/errorUtils";
|
import { formatReduxError } from "../../utils/errorUtils";
|
||||||
import { browserDefaultTimeZone } from "../../utils/timezone";
|
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
|
// Define error type for rejected actions
|
||||||
interface RejectedError {
|
export interface RejectedError {
|
||||||
message: string;
|
message: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getCalendarsListAsync = createAsyncThunk<
|
|
||||||
{ importedCalendars: Record<string, Calendar>; 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<string, Calendar> = {};
|
|
||||||
const user = (await getOpenPaasUser()) as Record<string, string>;
|
|
||||||
const calendars = (await getCalendars(user.id)) as Record<string, any>;
|
|
||||||
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<string, any>();
|
|
||||||
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<
|
export const getTempCalendarsListAsync = createAsyncThunk<
|
||||||
Record<string, Calendar>,
|
Record<string, Calendar>,
|
||||||
User,
|
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<
|
export const putEventAsync = createAsyncThunk<
|
||||||
{ calId: string; events: CalendarEvent[]; calType?: "temp" },
|
{ calId: string; events: CalendarEvent[]; calType?: "temp" },
|
||||||
{ cal: Calendar; newEvent: CalendarEvent; calType?: "temp" },
|
{ cal: Calendar; newEvent: CalendarEvent; calType?: "temp" },
|
||||||
@@ -692,9 +531,9 @@ const CalendarSlice = createSlice({
|
|||||||
action.payload.event;
|
action.payload.event;
|
||||||
state.list[action.payload.calendarUid].events[
|
state.list[action.payload.calendarUid].events[
|
||||||
action.payload.event.uid
|
action.payload.event.uid
|
||||||
].URL = `/calendars/${action.payload.calendarUid}/${
|
].URL = `/calendars/${action.payload.calendarUid}/${extractEventBaseUuid(
|
||||||
action.payload.event.uid.split("/")[0]
|
action.payload.event.uid
|
||||||
}.isc`;
|
)}.ics`;
|
||||||
},
|
},
|
||||||
removeEvent: (
|
removeEvent: (
|
||||||
state,
|
state,
|
||||||
@@ -781,6 +620,7 @@ const CalendarSlice = createSlice({
|
|||||||
calId: string;
|
calId: string;
|
||||||
events: CalendarEvent[];
|
events: CalendarEvent[];
|
||||||
calType?: string;
|
calType?: string;
|
||||||
|
syncToken?: string;
|
||||||
}>
|
}>
|
||||||
) => {
|
) => {
|
||||||
state.pending = false;
|
state.pending = false;
|
||||||
@@ -789,6 +629,8 @@ const CalendarSlice = createSlice({
|
|||||||
if (!state[type][action.payload.calId]) {
|
if (!state[type][action.payload.calId]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
state[type][action.payload.calId].syncToken =
|
||||||
|
action.payload.syncToken;
|
||||||
action.payload.events.forEach((event) => {
|
action.payload.events.forEach((event) => {
|
||||||
state[type][action.payload.calId].events[event.uid] = event;
|
state[type][action.payload.calId].events[event.uid] = event;
|
||||||
});
|
});
|
||||||
@@ -894,7 +736,7 @@ const CalendarSlice = createSlice({
|
|||||||
if (recurrenceId) {
|
if (recurrenceId) {
|
||||||
Object.keys(state.list[action.payload.calId].events).forEach(
|
Object.keys(state.list[action.payload.calId].events).forEach(
|
||||||
(element) => {
|
(element) => {
|
||||||
if (element.split("/")[0] === baseId) {
|
if (extractEventBaseUuid(element) === baseId) {
|
||||||
delete state.list[action.payload.calId].events[element];
|
delete state.list[action.payload.calId].events[element];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -991,6 +833,41 @@ const CalendarSlice = createSlice({
|
|||||||
state.pending = false;
|
state.pending = false;
|
||||||
state.error = null;
|
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
|
// Pending cases
|
||||||
.addCase(getCalendarDetailAsync.pending, (state) => {
|
.addCase(getCalendarDetailAsync.pending, (state) => {
|
||||||
state.pending = true;
|
state.pending = true;
|
||||||
@@ -1040,6 +917,9 @@ const CalendarSlice = createSlice({
|
|||||||
.addCase(importEventFromFileAsync.pending, (state) => {
|
.addCase(importEventFromFileAsync.pending, (state) => {
|
||||||
state.pending = true;
|
state.pending = true;
|
||||||
})
|
})
|
||||||
|
.addCase(refreshCalendarWithSyncToken.pending, (state) => {
|
||||||
|
state.pending = true;
|
||||||
|
})
|
||||||
// Rejected cases
|
// Rejected cases
|
||||||
.addCase(getCalendarsListAsync.rejected, (state, action) => {
|
.addCase(getCalendarsListAsync.rejected, (state, action) => {
|
||||||
if (action.payload?.status !== 401) {
|
if (action.payload?.status !== 401) {
|
||||||
@@ -1166,6 +1046,13 @@ const CalendarSlice = createSlice({
|
|||||||
action.payload?.message ||
|
action.payload?.message ||
|
||||||
action.error.message ||
|
action.error.message ||
|
||||||
"Failed to import event from file";
|
"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";
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ export interface Calendar {
|
|||||||
events: Record<string, CalendarEvent>;
|
events: Record<string, CalendarEvent>;
|
||||||
visibility: "private" | "public";
|
visibility: "private" | "public";
|
||||||
lastCacheCleared?: number;
|
lastCacheCleared?: number;
|
||||||
|
syncToken?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { api } from "../../../utils/apiUtils";
|
||||||
|
import { Calendar } from "../CalendarTypes";
|
||||||
|
import { DavSyncResponse } from "./types";
|
||||||
|
|
||||||
|
export async function fetchSyncTokenChanges(
|
||||||
|
calendar: Calendar
|
||||||
|
): Promise<DavSyncResponse> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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<string, Calendar>; 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<string, Calendar> = {};
|
||||||
|
const user = existingUser.id
|
||||||
|
? existingUser
|
||||||
|
: ((await getOpenPaasUser()) as Record<string, string>);
|
||||||
|
const calendars = (await getCalendars(user.id)) as Record<string, any>;
|
||||||
|
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<string, any>();
|
||||||
|
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<string, Calendar> = {};
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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<CalendarEvent[] | undefined> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, string>;
|
||||||
|
}
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { getCalendarVisibility } from "../../../components/Calendar/utils/calendarUtils";
|
||||||
|
|
||||||
|
export function normalizeCalendar(rawCalendar: Record<string, any>) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
import ICAL from "ical.js";
|
import ICAL from "ical.js";
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
import { detectDateTimeFormat } from "../../components/Event/utils/dateTimeHelpers";
|
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 {
|
function resolveTimezoneId(tzid?: string): string | undefined {
|
||||||
if (!tzid) return undefined;
|
if (!tzid) return undefined;
|
||||||
@@ -23,6 +24,22 @@ function resolveTimezoneId(tzid?: string): string | undefined {
|
|||||||
return tzid;
|
return tzid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reportEvent(
|
||||||
|
event: CalendarEvent,
|
||||||
|
match: { start: string; end: string }
|
||||||
|
): Promise<CalDavItem> {
|
||||||
|
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) {
|
export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
|
||||||
const response = await api.get(`dav${event.URL}`);
|
const response = await api.get(`dav${event.URL}`);
|
||||||
const eventData = await response.text();
|
const eventData = await response.text();
|
||||||
@@ -227,7 +244,7 @@ export const deleteEventInstance = async (
|
|||||||
const seriesEvent = await getEvent(
|
const seriesEvent = await getEvent(
|
||||||
{
|
{
|
||||||
...event,
|
...event,
|
||||||
uid: event.uid.split("/")[0],
|
uid: extractEventBaseUuid(event.uid),
|
||||||
},
|
},
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
EventFormContext,
|
EventFormContext,
|
||||||
} from "../../utils/eventFormTempStorage";
|
} from "../../utils/eventFormTempStorage";
|
||||||
import { browserDefaultTimeZone } from "../../utils/timezone";
|
import { browserDefaultTimeZone } from "../../utils/timezone";
|
||||||
|
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
||||||
|
|
||||||
function EventUpdateModal({
|
function EventUpdateModal({
|
||||||
eventId,
|
eventId,
|
||||||
@@ -261,7 +262,7 @@ function EventUpdateModal({
|
|||||||
setCalendarid(calId);
|
setCalendarid(calId);
|
||||||
|
|
||||||
// Handle repetition properly - check both current event and base event
|
// 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 baseEvent = calendarsList[calId]?.events[baseEventId];
|
||||||
const repetitionSource = event.repetition || baseEvent?.repetition;
|
const repetitionSource = event.repetition || baseEvent?.repetition;
|
||||||
|
|
||||||
@@ -478,7 +479,7 @@ function EventUpdateModal({
|
|||||||
|
|
||||||
Object.keys(seriesEvents).forEach((eventId) => {
|
Object.keys(seriesEvents).forEach((eventId) => {
|
||||||
const instance = seriesEvents[eventId];
|
const instance = seriesEvents[eventId];
|
||||||
if (instance && eventId.split("/")[0] === baseUID) {
|
if (instance && extractEventBaseUuid(eventId) === baseUID) {
|
||||||
instances[eventId] = { ...instance };
|
instances[eventId] = { ...instance };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -614,7 +615,7 @@ function EventUpdateModal({
|
|||||||
event.repetition?.freq &&
|
event.repetition?.freq &&
|
||||||
!repetition.freq
|
!repetition.freq
|
||||||
) {
|
) {
|
||||||
const baseUID = event.uid.split("/")[0];
|
const baseUID = extractEventBaseUuid(event.uid);
|
||||||
|
|
||||||
// Save current form data to temp storage before closing
|
// Save current form data to temp storage before closing
|
||||||
const formDataToSave = saveCurrentFormData();
|
const formDataToSave = saveCurrentFormData();
|
||||||
@@ -653,7 +654,7 @@ function EventUpdateModal({
|
|||||||
|
|
||||||
// Collect all instances that need to be deleted
|
// Collect all instances that need to be deleted
|
||||||
const instancesToDelete = Object.keys(targetCalendar.events)
|
const instancesToDelete = Object.keys(targetCalendar.events)
|
||||||
.filter((eventId) => eventId.split("/")[0] === baseUID)
|
.filter((eventId) => extractEventBaseUuid(eventId) === baseUID)
|
||||||
.map((eventId) => targetCalendar.events[eventId]);
|
.map((eventId) => targetCalendar.events[eventId]);
|
||||||
|
|
||||||
// Get unique URLs to avoid deleting same file multiple times
|
// Get unique URLs to avoid deleting same file multiple times
|
||||||
@@ -819,7 +820,7 @@ function EventUpdateModal({
|
|||||||
}
|
}
|
||||||
} else if (typeOfAction === "all") {
|
} else if (typeOfAction === "all") {
|
||||||
// Update all instances - check if repetition rules changed
|
// Update all instances - check if repetition rules changed
|
||||||
const baseUID = event.uid.split("/")[0];
|
const baseUID = extractEventBaseUuid(event.uid);
|
||||||
|
|
||||||
const changes = detectRecurringEventChanges(
|
const changes = detectRecurringEventChanges(
|
||||||
event,
|
event,
|
||||||
@@ -1074,7 +1075,7 @@ function EventUpdateModal({
|
|||||||
moveEventAsync({
|
moveEventAsync({
|
||||||
cal: targetCalendar,
|
cal: targetCalendar,
|
||||||
newEvent,
|
newEvent,
|
||||||
newURL: `/calendars/${newCalId}/${event.uid.split("/")[0]}.ics`,
|
newURL: `/calendars/${newCalId}/${extractEventBaseUuid(event.uid)}.ics`,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
detectDateTimeFormat,
|
detectDateTimeFormat,
|
||||||
} from "../../components/Event/utils/dateTimeHelpers";
|
} from "../../components/Event/utils/dateTimeHelpers";
|
||||||
import { createAttendee } from "../User/models/attendee.mapper";
|
import { createAttendee } from "../User/models/attendee.mapper";
|
||||||
|
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
||||||
type RawEntry = [string, Record<string, string>, string, any];
|
type RawEntry = [string, Record<string, string>, string, any];
|
||||||
|
|
||||||
function resolveTimezoneId(tzid?: string): string | undefined {
|
function resolveTimezoneId(tzid?: string): string | undefined {
|
||||||
@@ -189,7 +190,7 @@ export function parseCalendarEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
event.calId = calendarid;
|
||||||
event.URL = eventURL;
|
event.URL = eventURL;
|
||||||
if (!event.uid || !event.start) {
|
if (!event.uid || !event.start) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -293,7 +294,7 @@ export function makeVevent(
|
|||||||
const vevent: any[] = [
|
const vevent: any[] = [
|
||||||
"vevent",
|
"vevent",
|
||||||
[
|
[
|
||||||
["uid", {}, "text", event.uid.split("/")[0]],
|
["uid", {}, "text", extractEventBaseUuid(event.uid)],
|
||||||
["transp", {}, "text", event.transp ?? "OPAQUE"],
|
["transp", {}, "text", event.transp ?? "OPAQUE"],
|
||||||
[
|
[
|
||||||
"dtstart",
|
"dtstart",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { userAttendee } from "../User/models/attendee";
|
|||||||
import { formatDateTimeInTimezone } from "../../components/Event/utils/dateTimeFormatters";
|
import { formatDateTimeInTimezone } from "../../components/Event/utils/dateTimeFormatters";
|
||||||
import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils";
|
import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils";
|
||||||
import { browserDefaultTimeZone } from "../../utils/timezone";
|
import { browserDefaultTimeZone } from "../../utils/timezone";
|
||||||
|
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
||||||
|
|
||||||
export interface TimezoneListResult {
|
export interface TimezoneListResult {
|
||||||
zones: string[];
|
zones: string[];
|
||||||
@@ -133,7 +134,7 @@ export function populateFormFromEvent(
|
|||||||
// Handle repetition - check both current event and base event (for update modal)
|
// Handle repetition - check both current event and base event (for update modal)
|
||||||
let repetitionSource = event.repetition;
|
let repetitionSource = event.repetition;
|
||||||
if (calendarsList && calId && event.uid) {
|
if (calendarsList && calId && event.uid) {
|
||||||
const baseEventId = event.uid.split("/")[0];
|
const baseEventId = extractEventBaseUuid(event.uid);
|
||||||
const baseEvent = calendarsList[calId]?.events[baseEventId];
|
const baseEvent = calendarsList[calId]?.events[baseEventId];
|
||||||
if (baseEvent?.repetition) {
|
if (baseEvent?.repetition) {
|
||||||
repetitionSource = baseEvent.repetition;
|
repetitionSource = baseEvent.repetition;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import { 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";
|
||||||
import { push } from "redux-first-history";
|
import { push } from "redux-first-history";
|
||||||
import { redirectTo } from "../../utils/apiUtils";
|
import { redirectTo } from "../../utils/apiUtils";
|
||||||
import { getOpenPaasUserDataAsync, setTokens, setUserData } from "./userSlice";
|
import { getOpenPaasUserDataAsync, setTokens, setUserData } from "./userSlice";
|
||||||
import { getCalendarsListAsync } from "../Calendars/CalendarSlice";
|
import { getCalendarsListAsync } from "../Calendars/services/getCalendarsListAsync";
|
||||||
|
|
||||||
export function HandleLogin() {
|
export function HandleLogin() {
|
||||||
const userData = useAppSelector((state) => state.user);
|
const userData = useAppSelector((state) => state.user);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
setUserError,
|
setUserError,
|
||||||
} from "./userSlice";
|
} from "./userSlice";
|
||||||
import { Loading } from "../../components/Loading/Loading";
|
import { Loading } from "../../components/Loading/Loading";
|
||||||
import { getCalendarsListAsync } from "../Calendars/CalendarSlice";
|
import { getCalendarsListAsync } from "../Calendars/services/getCalendarsListAsync";
|
||||||
|
|
||||||
export function CallbackResume() {
|
export function CallbackResume() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function extractEventBaseUuid(eventKey: string) {
|
||||||
|
if (!eventKey) return "";
|
||||||
|
return eventKey.split("/")[0];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user