[DELEGATION] Edit attendance and events, delete event for someone (#553)
* [#523] added way to manage attendance for delegated calendars * [DELEGATION] #523 & #524 changed eventURL calculation for delegated events to have the rigth one * [#524] added drag and drop authorisation to delegated event * [#524] added tests * [#524] support for the permission access * [#524] no edit when delegated event is not public
This commit is contained in:
@@ -44,7 +44,7 @@ describe("CalendarSelection", () => {
|
|||||||
name: "Calendar personal",
|
name: "Calendar personal",
|
||||||
id: "user1/cal1",
|
id: "user1/cal1",
|
||||||
color: { light: "#FF0000", dark: "#000" },
|
color: { light: "#FF0000", dark: "#000" },
|
||||||
ownerEmails: ["alice@example.com"],
|
owner: { emails: ["alice@example.com"] },
|
||||||
events: {
|
events: {
|
||||||
event1: {
|
event1: {
|
||||||
id: "event1",
|
id: "event1",
|
||||||
@@ -76,7 +76,7 @@ describe("CalendarSelection", () => {
|
|||||||
delegated: true,
|
delegated: true,
|
||||||
id: "user2/cal1",
|
id: "user2/cal1",
|
||||||
color: { light: "#FF0000", dark: "#000" },
|
color: { light: "#FF0000", dark: "#000" },
|
||||||
ownerEmails: ["alice@example.com"],
|
owner: { emails: ["alice@example.com"] },
|
||||||
events: {
|
events: {
|
||||||
event1: {
|
event1: {
|
||||||
id: "event1",
|
id: "event1",
|
||||||
@@ -107,7 +107,7 @@ describe("CalendarSelection", () => {
|
|||||||
name: "Calendar shared",
|
name: "Calendar shared",
|
||||||
id: "user3/cal1",
|
id: "user3/cal1",
|
||||||
color: { light: "#FF0000", dark: "#000" },
|
color: { light: "#FF0000", dark: "#000" },
|
||||||
ownerEmails: ["alice@example.com"],
|
owner: { emails: ["alice@example.com"] },
|
||||||
events: {
|
events: {
|
||||||
event1: {
|
event1: {
|
||||||
id: "event1",
|
id: "event1",
|
||||||
@@ -336,7 +336,7 @@ describe("calendar Availability search", () => {
|
|||||||
name: "Calendar personal",
|
name: "Calendar personal",
|
||||||
id: "user1/cal1",
|
id: "user1/cal1",
|
||||||
color: { light: "#FF0000", dark: "#000" },
|
color: { light: "#FF0000", dark: "#000" },
|
||||||
ownerEmails: ["alice@example.com"],
|
owner: { emails: ["alice@example.com"] },
|
||||||
events: {},
|
events: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe("CalendarSearch", () => {
|
|||||||
name: "My Calendar",
|
name: "My Calendar",
|
||||||
id: "user1/cal1",
|
id: "user1/cal1",
|
||||||
color: "#0000FF",
|
color: "#0000FF",
|
||||||
ownerEmails: ["test@test.com"],
|
owner: { emails: ["test@test.com"] },
|
||||||
events: {},
|
events: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,20 +24,20 @@ describe("CalendarSelection", () => {
|
|||||||
name: "Calendar personal",
|
name: "Calendar personal",
|
||||||
id: "user1/cal1",
|
id: "user1/cal1",
|
||||||
color: "#FF0000",
|
color: "#FF0000",
|
||||||
ownerEmails: ["alice@example.com"],
|
owner: { emails: ["alice@example.com"] },
|
||||||
},
|
},
|
||||||
"user2/cal1": {
|
"user2/cal1": {
|
||||||
name: "Calendar delegated",
|
name: "Calendar delegated",
|
||||||
delegated: true,
|
delegated: true,
|
||||||
id: "user2/cal1",
|
id: "user2/cal1",
|
||||||
color: "#00FF00",
|
color: "#00FF00",
|
||||||
ownerEmails: ["bob@example.com"],
|
owner: { emails: ["bob@example.com"] },
|
||||||
},
|
},
|
||||||
"user3/cal1": {
|
"user3/cal1": {
|
||||||
name: "Calendar shared",
|
name: "Calendar shared",
|
||||||
id: "user3/cal1",
|
id: "user3/cal1",
|
||||||
color: "#0000FF",
|
color: "#0000FF",
|
||||||
ownerEmails: ["charlie@example.com"],
|
owner: { emails: ["charlie@example.com"] },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe("Event Error Handling", () => {
|
|||||||
name: "Calendar personal",
|
name: "Calendar personal",
|
||||||
id: "user1/cal1",
|
id: "user1/cal1",
|
||||||
color: { light: "#FF0000", dark: "#000" },
|
color: { light: "#FF0000", dark: "#000" },
|
||||||
ownerEmails: ["alice@example.com"],
|
owner: { emails: ["alice@example.com"] },
|
||||||
events: {
|
events: {
|
||||||
event1: {
|
event1: {
|
||||||
id: "event1",
|
id: "event1",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe("CalendarApp integration", () => {
|
|||||||
name: "Calendar 1",
|
name: "Calendar 1",
|
||||||
id: "667037022b752d0026472254/cal1",
|
id: "667037022b752d0026472254/cal1",
|
||||||
color: { light: "#FFFFFF", dark: "#000000" },
|
color: { light: "#FFFFFF", dark: "#000000" },
|
||||||
ownerEmails: ["alice@example.com"],
|
owner: { emails: ["alice@example.com"] },
|
||||||
events: {
|
events: {
|
||||||
event1: {
|
event1: {
|
||||||
id: "event1",
|
id: "event1",
|
||||||
@@ -136,7 +136,7 @@ describe("CalendarApp integration", () => {
|
|||||||
name: "Calendar 1",
|
name: "Calendar 1",
|
||||||
id: "667037022b752d0026472254/cal1",
|
id: "667037022b752d0026472254/cal1",
|
||||||
color: { light: "#FFFFFF", dark: "#000000" },
|
color: { light: "#FFFFFF", dark: "#000000" },
|
||||||
ownerEmails: ["alice@example.com"],
|
owner: { emails: ["alice@example.com"] },
|
||||||
events: {
|
events: {
|
||||||
event1: {
|
event1: {
|
||||||
id: "event1",
|
id: "event1",
|
||||||
@@ -247,7 +247,7 @@ describe("CalendarApp integration", () => {
|
|||||||
name: "Calendar 1",
|
name: "Calendar 1",
|
||||||
id: "667037022b752d0026472254/cal1",
|
id: "667037022b752d0026472254/cal1",
|
||||||
color: { light: "#FF0000", dark: "#000" },
|
color: { light: "#FF0000", dark: "#000" },
|
||||||
ownerEmails: ["alice@example.com"],
|
owner: { emails: ["alice@example.com"] },
|
||||||
events: {
|
events: {
|
||||||
event1: {
|
event1: {
|
||||||
id: "event1",
|
id: "event1",
|
||||||
@@ -291,7 +291,7 @@ describe("CalendarApp integration", () => {
|
|||||||
name: "Calendar 1",
|
name: "Calendar 1",
|
||||||
id: "667037022b752d0026472254/cal1",
|
id: "667037022b752d0026472254/cal1",
|
||||||
color: { light: "#FFFFFF", dark: "#000000" },
|
color: { light: "#FFFFFF", dark: "#000000" },
|
||||||
ownerEmails: ["alice@example.com"],
|
owner: { emails: ["alice@example.com"] },
|
||||||
events: {
|
events: {
|
||||||
event1: {
|
event1: {
|
||||||
id: "event1",
|
id: "event1",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ describe("MiniCalendar", () => {
|
|||||||
"667037022b752d0026472254/cal1": {
|
"667037022b752d0026472254/cal1": {
|
||||||
name: "Calendar 1",
|
name: "Calendar 1",
|
||||||
color: { light: "#FF0000", dark: "#000" },
|
color: { light: "#FF0000", dark: "#000" },
|
||||||
ownerEmails: ["test@test.com"],
|
owner: { emails: ["test@test.com"] },
|
||||||
events: {
|
events: {
|
||||||
event1: {
|
event1: {
|
||||||
calId: "667037022b752d0026472254/cal1",
|
calId: "667037022b752d0026472254/cal1",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe("Calendar API", () => {
|
|||||||
const calendars = await getCalendars(mockUserId);
|
const calendars = await getCalendars(mockUserId);
|
||||||
|
|
||||||
expect(api.get).toHaveBeenCalledWith(
|
expect(api.get).toHaveBeenCalledWith(
|
||||||
`dav/calendars/${mockUserId}.json?personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&`,
|
`dav/calendars/${mockUserId}.json?personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&withRights=true`,
|
||||||
{
|
{
|
||||||
headers: { Accept: "application/calendar+json" },
|
headers: { Accept: "application/calendar+json" },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,8 +109,7 @@ describe("CalendarPopover (editing mode)", () => {
|
|||||||
name: "Work Calendar",
|
name: "Work Calendar",
|
||||||
description: "Team meetings",
|
description: "Team meetings",
|
||||||
color: { light: "#33B679" },
|
color: { light: "#33B679" },
|
||||||
owner: "alice",
|
owner: { firstname: "alice", emails: ["alice@example.com"] },
|
||||||
ownerEmails: ["alice@example.com"],
|
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
events: {},
|
events: {},
|
||||||
};
|
};
|
||||||
@@ -214,8 +213,7 @@ describe("CalendarPopover - Tabs Scenarios", () => {
|
|||||||
name: "Work Calendar",
|
name: "Work Calendar",
|
||||||
description: "Team meetings",
|
description: "Team meetings",
|
||||||
color: { light: "#33B679" },
|
color: { light: "#33B679" },
|
||||||
owner: "alice",
|
owner: { firstname: "alice", emails: ["alice@example.com"] },
|
||||||
ownerEmails: ["alice@example.com"],
|
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
events: {},
|
events: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -195,9 +195,9 @@ describe("CalendarSlice", () => {
|
|||||||
expect(getUserDetailsMock).toHaveBeenCalledWith("u3");
|
expect(getUserDetailsMock).toHaveBeenCalledWith("u3");
|
||||||
|
|
||||||
const state = store.getState().calendars;
|
const state = store.getState().calendars;
|
||||||
expect(state.list["u1/cal1"].owner).toContain("Alice");
|
expect(state.list["u1/cal1"].owner.firstname).toContain("Alice");
|
||||||
expect(state.list["u2/cal2"].owner).toContain("Bob");
|
expect(state.list["u2/cal2"].owner.firstname).toContain("Bob");
|
||||||
expect(state.list["u3/cal3"].owner).toContain("Charlie");
|
expect(state.list["u3/cal3"].owner.firstname).toContain("Charlie");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getCalendarsListAsync deduplicates getUserDetails calls for same ownerId", async () => {
|
it("getCalendarsListAsync deduplicates getUserDetails calls for same ownerId", async () => {
|
||||||
@@ -331,8 +331,8 @@ describe("CalendarSlice", () => {
|
|||||||
|
|
||||||
expect(getUserDetailsMock).toHaveBeenCalledTimes(2);
|
expect(getUserDetailsMock).toHaveBeenCalledTimes(2);
|
||||||
const state = store.getState().calendars;
|
const state = store.getState().calendars;
|
||||||
expect(state.list["u1/cal1"].owner).toContain("Alice");
|
expect(state.list["u1/cal1"].owner.firstname).toContain("Alice");
|
||||||
expect(state.list["u2/cal2"].owner).toContain("Unknown User");
|
expect(state.list["u2/cal2"].owner.lastname).toContain("Unknown User");
|
||||||
expect(result.payload.errors).toBeTruthy();
|
expect(result.payload.errors).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -375,8 +375,7 @@ describe("CalendarSlice", () => {
|
|||||||
color: { "apple:color": "#f00" },
|
color: { "apple:color": "#f00" },
|
||||||
name: "Test",
|
name: "Test",
|
||||||
desc: "Desc",
|
desc: "Desc",
|
||||||
owner: "Owner",
|
owner: { firstname: "Owner", emails: ["o@example.com"] },
|
||||||
ownerEmails: ["o@example.com"],
|
|
||||||
};
|
};
|
||||||
const state = reducer(
|
const state = reducer(
|
||||||
initialState,
|
initialState,
|
||||||
@@ -393,8 +392,7 @@ describe("CalendarSlice", () => {
|
|||||||
link: "/calendars/u1/c1.json",
|
link: "/calendars/u1/c1.json",
|
||||||
name: "Shared",
|
name: "Shared",
|
||||||
desc: "Shared Desc",
|
desc: "Shared Desc",
|
||||||
owner: "O",
|
owner: { firstname: "O", emails: ["o@example.com"] },
|
||||||
ownerEmails: ["o@example.com"],
|
|
||||||
};
|
};
|
||||||
const mockCal = {
|
const mockCal = {
|
||||||
cal: {
|
cal: {
|
||||||
@@ -423,8 +421,7 @@ describe("CalendarSlice", () => {
|
|||||||
color: { "apple:color": "#aaa" },
|
color: { "apple:color": "#aaa" },
|
||||||
events: {},
|
events: {},
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
owner: "O",
|
owner: { firstname: "O", emails: ["o@o.com"] },
|
||||||
ownerEmails: ["o@o.com"],
|
|
||||||
link: "/calendars/t1.json",
|
link: "/calendars/t1.json",
|
||||||
description: "desc",
|
description: "desc",
|
||||||
} as Calendar,
|
} as Calendar,
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ describe("Calendar - Timezone Integration", () => {
|
|||||||
name: "Test Calendar",
|
name: "Test Calendar",
|
||||||
description: "",
|
description: "",
|
||||||
color: "#33B679",
|
color: "#33B679",
|
||||||
owner: "user1",
|
owner: { firstname: "user1", emails: ["test@test.com"] },
|
||||||
ownerEmails: ["test@test.com"],
|
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
events: {},
|
events: {},
|
||||||
},
|
},
|
||||||
@@ -174,8 +173,7 @@ describe("EventDisplayPreview - Timezone Display", () => {
|
|||||||
id: "user1/cal1",
|
id: "user1/cal1",
|
||||||
name: "Test Calendar",
|
name: "Test Calendar",
|
||||||
color: "#33B679",
|
color: "#33B679",
|
||||||
owner: "user1",
|
owner: { firstname: "user1", emails: ["test@test.com"] },
|
||||||
ownerEmails: ["test@test.com"],
|
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
events: {
|
events: {
|
||||||
event1: baseEvent,
|
event1: baseEvent,
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ describe("refreshCalendarWithSyncToken", () => {
|
|||||||
id: "user1/cal1",
|
id: "user1/cal1",
|
||||||
name: "Test Calendar",
|
name: "Test Calendar",
|
||||||
link: "/calendars/user1/cal1.json",
|
link: "/calendars/user1/cal1.json",
|
||||||
owner: "Test User",
|
owner: { firstname: "Test User", emails: ["test@example.com"] },
|
||||||
ownerEmails: ["test@example.com"],
|
|
||||||
description: "Test calendar description",
|
description: "Test calendar description",
|
||||||
delegated: false,
|
delegated: false,
|
||||||
color: { light: "#006BD8", dark: "#FFF" },
|
color: { light: "#006BD8", dark: "#FFF" },
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Calendar } from "@/features/Calendars/CalendarTypes";
|
||||||
|
import { AttendanceValidation } from "@/features/Events/AttendanceValidation/AttendanceValidation";
|
||||||
|
import { ContextualizedEvent } from "@/features/Events/EventsTypes";
|
||||||
|
import { userAttendee } from "@/features/User/models/attendee";
|
||||||
|
import { userData } from "@/features/User/userDataTypes";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
|
||||||
|
jest.mock("twake-i18n", () => ({
|
||||||
|
useI18n: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/app/hooks", () => ({
|
||||||
|
useAppDispatch: () => jest.fn(),
|
||||||
|
useAppSelector: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const makeUser = (email: string): userData => ({
|
||||||
|
email,
|
||||||
|
given_name: "Alice",
|
||||||
|
family_name: "User",
|
||||||
|
name: "Alice User",
|
||||||
|
sid: "user1",
|
||||||
|
sub: "user1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeContext = (
|
||||||
|
overrides: Partial<ContextualizedEvent> = {}
|
||||||
|
): ContextualizedEvent =>
|
||||||
|
({
|
||||||
|
event: {
|
||||||
|
uid: "event-1",
|
||||||
|
calId: "user1/cal1",
|
||||||
|
title: "Test",
|
||||||
|
start: "2024-01-15T10:00:00",
|
||||||
|
end: "2024-01-15T11:00:00",
|
||||||
|
organizer: { cal_address: "owner@example.com" },
|
||||||
|
attendee: [
|
||||||
|
{ cal_address: "owner@example.com", partstat: "NEEDS-ACTION" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
id: "user1/cal1",
|
||||||
|
name: "Test",
|
||||||
|
delegated: false,
|
||||||
|
owner: { emails: ["alice@example.com"] },
|
||||||
|
events: {},
|
||||||
|
},
|
||||||
|
currentUserAttendee: {
|
||||||
|
cal_address: "alice@example.com",
|
||||||
|
partstat: "NEEDS-ACTION",
|
||||||
|
},
|
||||||
|
isOwn: true,
|
||||||
|
isOrganizer: false,
|
||||||
|
isRecurring: false,
|
||||||
|
...overrides,
|
||||||
|
}) as unknown as ContextualizedEvent;
|
||||||
|
|
||||||
|
const noopSetFunc = jest.fn();
|
||||||
|
|
||||||
|
describe("AttendanceValidation - delegation", () => {
|
||||||
|
describe("non-delegated calendar", () => {
|
||||||
|
it("renders when isOwn and currentUserAttendee exists", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AttendanceValidation
|
||||||
|
contextualizedEvent={makeContext({ isOwn: true })}
|
||||||
|
user={makeUser("alice@example.com")}
|
||||||
|
setAfterChoiceFunc={noopSetFunc}
|
||||||
|
setOpenEditModePopup={noopSetFunc}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(container.firstChild).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when not isOwn and not delegated", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AttendanceValidation
|
||||||
|
contextualizedEvent={makeContext({ isOwn: false })}
|
||||||
|
user={makeUser("other@example.com")}
|
||||||
|
setAfterChoiceFunc={noopSetFunc}
|
||||||
|
setOpenEditModePopup={noopSetFunc}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("delegated calendar", () => {
|
||||||
|
const delegatedContext = makeContext({
|
||||||
|
isOwn: false, // logged-in user is not the owner
|
||||||
|
calendar: {
|
||||||
|
id: "user2/cal1",
|
||||||
|
name: "Delegated Cal",
|
||||||
|
delegated: true,
|
||||||
|
owner: { emails: ["owner@example.com"] },
|
||||||
|
events: {},
|
||||||
|
} as Calendar,
|
||||||
|
currentUserAttendee: {
|
||||||
|
cal_address: "owner@example.com",
|
||||||
|
partstat: "NEEDS-ACTION",
|
||||||
|
} as userAttendee,
|
||||||
|
event: { class: "PUBLIC" },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders even when isOwn is false", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AttendanceValidation
|
||||||
|
contextualizedEvent={delegatedContext}
|
||||||
|
user={makeUser("alice@example.com")}
|
||||||
|
setAfterChoiceFunc={noopSetFunc}
|
||||||
|
setOpenEditModePopup={noopSetFunc}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(container.firstChild).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders regardless of currentUserAttendee presence", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AttendanceValidation
|
||||||
|
contextualizedEvent={{
|
||||||
|
...delegatedContext,
|
||||||
|
currentUserAttendee: undefined,
|
||||||
|
}}
|
||||||
|
user={makeUser("alice@example.com")}
|
||||||
|
setAfterChoiceFunc={noopSetFunc}
|
||||||
|
setOpenEditModePopup={noopSetFunc}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// delegated flag alone should keep it visible
|
||||||
|
expect(container.firstChild).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
stringAvatar,
|
stringAvatar,
|
||||||
stringToColor,
|
stringToColor,
|
||||||
} from "@/components/Event/utils/eventUtils";
|
} from "@/components/Event/utils/eventUtils";
|
||||||
import * as calendarSlice from "@/features/Calendars/CalendarSlice";
|
import { DelegationAccess } from "@/features/Calendars/CalendarTypes";
|
||||||
import * as eventThunks from "@/features/Calendars/services";
|
import * as eventThunks from "@/features/Calendars/services";
|
||||||
import EventPreviewModal from "@/features/Events/EventDisplayPreview";
|
import EventPreviewModal from "@/features/Events/EventDisplayPreview";
|
||||||
import EventUpdateModal from "@/features/Events/EventUpdateModal";
|
import EventUpdateModal from "@/features/Events/EventUpdateModal";
|
||||||
@@ -98,7 +98,7 @@ describe("Event Preview Display", () => {
|
|||||||
end: day.toISOString(),
|
end: day.toISOString(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ownerEmails: ["test@test.com"],
|
owner: { emails: ["test@test.com"] },
|
||||||
},
|
},
|
||||||
"otherCal/cal": {
|
"otherCal/cal": {
|
||||||
id: "otherCal/cal",
|
id: "otherCal/cal",
|
||||||
@@ -659,10 +659,6 @@ describe("Event Preview Display", () => {
|
|||||||
|
|
||||||
fireEvent.click(emailButton);
|
fireEvent.click(emailButton);
|
||||||
|
|
||||||
const event =
|
|
||||||
preloadedState.calendars.list["667037022b752d0026472254/cal1"].events[
|
|
||||||
"event1"
|
|
||||||
];
|
|
||||||
const expectedUrl = `test/mailto/?uri=mailto:john@test.com&subject=Test%20Event`;
|
const expectedUrl = `test/mailto/?uri=mailto:john@test.com&subject=Test%20Event`;
|
||||||
|
|
||||||
expect(mockOpen).toHaveBeenCalledWith(expectedUrl);
|
expect(mockOpen).toHaveBeenCalledWith(expectedUrl);
|
||||||
@@ -774,7 +770,7 @@ describe("Event Preview Display", () => {
|
|||||||
name: "Calendar 1",
|
name: "Calendar 1",
|
||||||
id: "667037022b752d0026472254/cal1",
|
id: "667037022b752d0026472254/cal1",
|
||||||
color: "#FF0000",
|
color: "#FF0000",
|
||||||
ownerEmails: ownerEmails,
|
owner: { emails: ownerEmails },
|
||||||
events: {
|
events: {
|
||||||
event1: {
|
event1: {
|
||||||
uid: "event1",
|
uid: "event1",
|
||||||
@@ -988,7 +984,7 @@ describe("Event Preview Display", () => {
|
|||||||
attendee: attendees,
|
attendee: attendees,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ownerEmails: ["test@test.com"],
|
owner: { emails: ["test@test.com"] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pending: false,
|
pending: false,
|
||||||
@@ -1555,6 +1551,161 @@ describe("Event Preview Display", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("EventDisplayPreview - delegation", () => {
|
||||||
|
const delegatedBaseEvent = {
|
||||||
|
uid: "event-1",
|
||||||
|
calId: "user2/cal1",
|
||||||
|
title: "Delegated Event",
|
||||||
|
start: day.toISOString(),
|
||||||
|
end: day.toISOString(),
|
||||||
|
organizer: { cal_address: "owner@example.com" },
|
||||||
|
attendee: [
|
||||||
|
{ cal_address: "owner@example.com", partstat: "NEEDS-ACTION" },
|
||||||
|
],
|
||||||
|
URL: "/calendars/user2/cal1/event-1.ics",
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeDelegatedState = (
|
||||||
|
eventOverrides = {},
|
||||||
|
access: DelegationAccess = {
|
||||||
|
write: true,
|
||||||
|
freebusy: false,
|
||||||
|
read: true,
|
||||||
|
"write-properties": false,
|
||||||
|
all: false,
|
||||||
|
}
|
||||||
|
) => ({
|
||||||
|
...preloadedState,
|
||||||
|
calendars: {
|
||||||
|
list: {
|
||||||
|
"user2/cal1": {
|
||||||
|
id: "user2/cal1",
|
||||||
|
name: "Delegated Calendar",
|
||||||
|
delegated: true,
|
||||||
|
access,
|
||||||
|
owner: {
|
||||||
|
id: "user2",
|
||||||
|
firstname: "Bob",
|
||||||
|
lastname: "Owner",
|
||||||
|
emails: ["owner@example.com"],
|
||||||
|
preferredEmail: "owner@example.com",
|
||||||
|
},
|
||||||
|
color: { light: "#FF0000", dark: "#000" },
|
||||||
|
events: {
|
||||||
|
"event-1": { ...delegatedBaseEvent, ...eventOverrides },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
templist: {},
|
||||||
|
pending: false,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
userData: {
|
||||||
|
...preloadedState.user.userData,
|
||||||
|
email: "alice@example.com",
|
||||||
|
openpaasId: "user1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edit button visibility", () => {
|
||||||
|
it("shows edit button when calendar is write-delegated and owner is organizer", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<EventPreviewModal
|
||||||
|
eventId="event-1"
|
||||||
|
calId="user2/cal1"
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
/>,
|
||||||
|
makeDelegatedState()
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByTestId("EditIcon").closest("button")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show edit button when delegated but owner is not organizer", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<EventPreviewModal
|
||||||
|
eventId="event-1"
|
||||||
|
calId="user2/cal1"
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
/>,
|
||||||
|
makeDelegatedState({
|
||||||
|
organizer: { cal_address: "someone-else@example.com" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("EditIcon")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show edit button when delegated with read-only access", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<EventPreviewModal
|
||||||
|
eventId="event-1"
|
||||||
|
calId="user2/cal1"
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
/>,
|
||||||
|
makeDelegatedState(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
write: false,
|
||||||
|
freebusy: false,
|
||||||
|
read: true,
|
||||||
|
"write-properties": false,
|
||||||
|
all: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("EditIcon")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("delete menu item visibility", () => {
|
||||||
|
it("shows delete option when calendar is write-delegated", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<EventPreviewModal
|
||||||
|
eventId="event-1"
|
||||||
|
calId="user2/cal1"
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
/>,
|
||||||
|
makeDelegatedState()
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId("MoreVertIcon"));
|
||||||
|
expect(
|
||||||
|
screen.getByText("eventPreview.deleteEvent")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show delete option when delegated with read-only access", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<EventPreviewModal
|
||||||
|
eventId="event-1"
|
||||||
|
calId="user2/cal1"
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
/>,
|
||||||
|
makeDelegatedState(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
write: false,
|
||||||
|
freebusy: false,
|
||||||
|
read: true,
|
||||||
|
"write-properties": false,
|
||||||
|
all: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId("MoreVertIcon"));
|
||||||
|
expect(
|
||||||
|
screen.queryByText("eventPreview.deleteEvent")
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("BUGFIX", () => {
|
describe("BUGFIX", () => {
|
||||||
it("doesnt render anything next to date of all day preview", () => {
|
it("doesnt render anything next to date of all day preview", () => {
|
||||||
const allDayState = {
|
const allDayState = {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ const basePreloadedState = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ownerEmails: ["test@test.com"],
|
owner: { emails: ["test@test.com"] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pending: false,
|
pending: false,
|
||||||
@@ -1151,7 +1151,7 @@ describe("Event URL handling for recurring events", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ownerEmails: ["test@test.com"],
|
owner: { emails: ["test@test.com"] },
|
||||||
},
|
},
|
||||||
"667037022b752d0026472254/cal2": {
|
"667037022b752d0026472254/cal2": {
|
||||||
id: "667037022b752d0026472254/cal2",
|
id: "667037022b752d0026472254/cal2",
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
|
|||||||
name: "Test Calendar",
|
name: "Test Calendar",
|
||||||
color: "#FF0000",
|
color: "#FF0000",
|
||||||
events: {},
|
events: {},
|
||||||
ownerEmails: ["test@test.com"],
|
owner: { emails: ["test@test.com"] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pending: false,
|
pending: false,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Calendar } from "@/features/Calendars/CalendarTypes";
|
||||||
|
import { VObjectProperty } from "@/features/Calendars/types/CalendarData";
|
||||||
import { CalendarEvent, RepetitionObject } from "@/features/Events/EventsTypes";
|
import { CalendarEvent, RepetitionObject } from "@/features/Events/EventsTypes";
|
||||||
import {
|
import {
|
||||||
calendarEventToJCal,
|
calendarEventToJCal,
|
||||||
@@ -10,7 +12,7 @@ import {
|
|||||||
|
|
||||||
describe("parseCalendarEvent", () => {
|
describe("parseCalendarEvent", () => {
|
||||||
const baseColor = { light: "#00FF00" };
|
const baseColor = { light: "#00FF00" };
|
||||||
const calendarId = "calendar-123";
|
const calendar = { id: "calendar-123" } as Calendar;
|
||||||
|
|
||||||
it("parses a full event with MAILTO in caps correctly", () => {
|
it("parses a full event with MAILTO in caps correctly", () => {
|
||||||
const rawData = [
|
const rawData = [
|
||||||
@@ -33,12 +35,12 @@ describe("parseCalendarEvent", () => {
|
|||||||
["TRANSP", {}, "text", "OPAQUE"],
|
["TRANSP", {}, "text", "OPAQUE"],
|
||||||
["CLASS", {}, "text", "PUBLIC"],
|
["CLASS", {}, "text", "PUBLIC"],
|
||||||
["DTSTAMP", {}, "date-time", "2025-07-18T08:00:00Z"],
|
["DTSTAMP", {}, "date-time", "2025-07-18T08:00:00Z"],
|
||||||
] as unknown as [string, Record<string, string>, string, any];
|
] as VObjectProperty[];
|
||||||
|
|
||||||
const result = parseCalendarEvent(
|
const result = parseCalendarEvent(
|
||||||
rawData,
|
rawData,
|
||||||
baseColor,
|
baseColor,
|
||||||
calendarId,
|
calendar,
|
||||||
"/calendars/test.ics"
|
"/calendars/test.ics"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -95,12 +97,12 @@ describe("parseCalendarEvent", () => {
|
|||||||
["TRANSP", {}, "text", "OPAQUE"],
|
["TRANSP", {}, "text", "OPAQUE"],
|
||||||
["CLASS", {}, "text", "PUBLIC"],
|
["CLASS", {}, "text", "PUBLIC"],
|
||||||
["DTSTAMP", {}, "date-time", "2025-07-18T08:00:00Z"],
|
["DTSTAMP", {}, "date-time", "2025-07-18T08:00:00Z"],
|
||||||
] as unknown as [string, Record<string, string>, string, any];
|
] as VObjectProperty[];
|
||||||
|
|
||||||
const result = parseCalendarEvent(
|
const result = parseCalendarEvent(
|
||||||
rawData,
|
rawData,
|
||||||
baseColor,
|
baseColor,
|
||||||
calendarId,
|
calendar,
|
||||||
"/calendars/test.ics"
|
"/calendars/test.ics"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -137,16 +139,16 @@ describe("parseCalendarEvent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("marks allday true for DATE format", () => {
|
it("marks allday true for DATE format", () => {
|
||||||
const rawData = [
|
const rawData: VObjectProperty[] = [
|
||||||
["UID", {}, "text", "event-2"],
|
["UID", {}, "text", "event-2"],
|
||||||
["DTSTART", {}, "date", "2025-07-20"],
|
["DTSTART", {}, "date", "2025-07-20"],
|
||||||
["DTEND", {}, "date", "2025-07-21"],
|
["DTEND", {}, "date", "2025-07-21"],
|
||||||
] as any;
|
];
|
||||||
|
|
||||||
const result = parseCalendarEvent(
|
const result = parseCalendarEvent(
|
||||||
rawData,
|
rawData,
|
||||||
baseColor,
|
baseColor,
|
||||||
calendarId,
|
calendar,
|
||||||
"/calendars/test.ics"
|
"/calendars/test.ics"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -154,7 +156,7 @@ describe("parseCalendarEvent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("appends recurrence-id to UID if present", () => {
|
it("appends recurrence-id to UID if present", () => {
|
||||||
const rawData: any = [
|
const rawData: VObjectProperty[] = [
|
||||||
["UID", {}, "text", "event-2"],
|
["UID", {}, "text", "event-2"],
|
||||||
["RECURRENCE-ID", {}, "date-time", "2025-07-18T09:00:00Z"],
|
["RECURRENCE-ID", {}, "date-time", "2025-07-18T09:00:00Z"],
|
||||||
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
||||||
@@ -163,7 +165,7 @@ describe("parseCalendarEvent", () => {
|
|||||||
const result = parseCalendarEvent(
|
const result = parseCalendarEvent(
|
||||||
rawData,
|
rawData,
|
||||||
baseColor,
|
baseColor,
|
||||||
calendarId,
|
calendar,
|
||||||
"/calendars/test.ics"
|
"/calendars/test.ics"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -171,24 +173,26 @@ describe("parseCalendarEvent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns error if UID or start is missing", () => {
|
it("returns error if UID or start is missing", () => {
|
||||||
const rawDataMissingUid: any = [
|
const rawDataMissingUid: VObjectProperty[] = [
|
||||||
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = parseCalendarEvent(
|
const result = parseCalendarEvent(
|
||||||
rawDataMissingUid,
|
rawDataMissingUid,
|
||||||
baseColor,
|
baseColor,
|
||||||
calendarId,
|
calendar,
|
||||||
"/calendars/test.ics"
|
"/calendars/test.ics"
|
||||||
);
|
);
|
||||||
expect(result.error).toMatch(/missing crucial event param/);
|
expect(result.error).toMatch(/missing crucial event param/);
|
||||||
|
|
||||||
const rawDataMissingStart: any = [["UID", {}, "text", "event-3"]];
|
const rawDataMissingStart: VObjectProperty[] = [
|
||||||
|
["UID", {}, "text", "event-3"],
|
||||||
|
];
|
||||||
|
|
||||||
const result2 = parseCalendarEvent(
|
const result2 = parseCalendarEvent(
|
||||||
rawDataMissingStart,
|
rawDataMissingStart,
|
||||||
baseColor,
|
baseColor,
|
||||||
calendarId,
|
calendar,
|
||||||
"/calendars/test.ics"
|
"/calendars/test.ics"
|
||||||
);
|
);
|
||||||
expect(result2.error).toMatch(/missing crucial event param/);
|
expect(result2.error).toMatch(/missing crucial event param/);
|
||||||
@@ -197,7 +201,7 @@ describe("parseCalendarEvent", () => {
|
|||||||
it("returns computed end when there is no end but a duration", () => {
|
it("returns computed end when there is no end but a duration", () => {
|
||||||
jest.useFakeTimers().setSystemTime(new Date("2025-07-18T00:00:00Z"));
|
jest.useFakeTimers().setSystemTime(new Date("2025-07-18T00:00:00Z"));
|
||||||
|
|
||||||
const rawDataMissing: any = [
|
const rawDataMissing: VObjectProperty[] = [
|
||||||
["UID", {}, "text", "event-1"],
|
["UID", {}, "text", "event-1"],
|
||||||
["DTSTART", {}, "date-time", "2025-07-18T09:00:00"],
|
["DTSTART", {}, "date-time", "2025-07-18T09:00:00"],
|
||||||
["duration", {}, "duration", "PT60M"],
|
["duration", {}, "duration", "PT60M"],
|
||||||
@@ -206,7 +210,7 @@ describe("parseCalendarEvent", () => {
|
|||||||
const result = parseCalendarEvent(
|
const result = parseCalendarEvent(
|
||||||
rawDataMissing,
|
rawDataMissing,
|
||||||
baseColor,
|
baseColor,
|
||||||
calendarId,
|
calendar,
|
||||||
"/calendars/test.ics"
|
"/calendars/test.ics"
|
||||||
);
|
);
|
||||||
expect(result.end).toBeDefined();
|
expect(result.end).toBeDefined();
|
||||||
@@ -216,7 +220,7 @@ describe("parseCalendarEvent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 30 minutes event if end and duration are missing", () => {
|
it("returns 30 minutes event if end and duration are missing", () => {
|
||||||
const rawDataMissing: any = [
|
const rawDataMissing: VObjectProperty[] = [
|
||||||
["UID", {}, "text", "event-1"],
|
["UID", {}, "text", "event-1"],
|
||||||
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
||||||
];
|
];
|
||||||
@@ -224,7 +228,7 @@ describe("parseCalendarEvent", () => {
|
|||||||
const result = parseCalendarEvent(
|
const result = parseCalendarEvent(
|
||||||
rawDataMissing,
|
rawDataMissing,
|
||||||
baseColor,
|
baseColor,
|
||||||
calendarId,
|
calendar,
|
||||||
"/calendars/test.ics"
|
"/calendars/test.ics"
|
||||||
);
|
);
|
||||||
expect(result.end).toBeDefined();
|
expect(result.end).toBeDefined();
|
||||||
@@ -234,23 +238,23 @@ describe("parseCalendarEvent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("parses alarm block correctly", () => {
|
it("parses alarm block correctly", () => {
|
||||||
const rawData = [
|
const rawData: VObjectProperty[] = [
|
||||||
["UID", {}, "text", "event-5"],
|
["UID", {}, "text", "event-5"],
|
||||||
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
||||||
] as any;
|
];
|
||||||
|
|
||||||
const valarm: any = [
|
const valarm = [
|
||||||
"VALARM",
|
"VALARM",
|
||||||
[
|
[
|
||||||
["ACTION", {}, "text", "DISPLAY"],
|
["ACTION", {}, "text", "DISPLAY"],
|
||||||
["TRIGGER", {}, "duration", "-PT15M"],
|
["TRIGGER", {}, "duration", "-PT15M"],
|
||||||
],
|
],
|
||||||
];
|
] as unknown as VObjectProperty[];
|
||||||
|
|
||||||
const result = parseCalendarEvent(
|
const result = parseCalendarEvent(
|
||||||
rawData,
|
rawData,
|
||||||
baseColor,
|
baseColor,
|
||||||
calendarId,
|
calendar,
|
||||||
"/calendars/test.ics",
|
"/calendars/test.ics",
|
||||||
valarm
|
valarm
|
||||||
);
|
);
|
||||||
@@ -266,12 +270,12 @@ describe("parseCalendarEvent", () => {
|
|||||||
["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"],
|
["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"],
|
||||||
["ATTENDEE", {}, "cal-address", "john@example.com"],
|
["ATTENDEE", {}, "cal-address", "john@example.com"],
|
||||||
["ORGANIZER", {}, "cal-address", "jane@example.com"],
|
["ORGANIZER", {}, "cal-address", "jane@example.com"],
|
||||||
] as unknown as [string, Record<string, string>, string, any];
|
] as VObjectProperty[];
|
||||||
|
|
||||||
const result = parseCalendarEvent(
|
const result = parseCalendarEvent(
|
||||||
rawData,
|
rawData,
|
||||||
baseColor,
|
baseColor,
|
||||||
calendarId,
|
calendar,
|
||||||
"/calendars/test.ics"
|
"/calendars/test.ics"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -297,12 +301,12 @@ describe("parseCalendarEvent", () => {
|
|||||||
["UID", {}, "text", "event-tz"],
|
["UID", {}, "text", "event-tz"],
|
||||||
["DTSTART", { tzid: "Asia/Bangkok" }, "date-time", "2025-07-18T09:00:00"],
|
["DTSTART", { tzid: "Asia/Bangkok" }, "date-time", "2025-07-18T09:00:00"],
|
||||||
["DTEND", { tzid: "Asia/Bangkok" }, "date-time", "2025-07-18T10:00:00"],
|
["DTEND", { tzid: "Asia/Bangkok" }, "date-time", "2025-07-18T10:00:00"],
|
||||||
] as unknown as [string, Record<string, string>, string, any];
|
] as VObjectProperty[];
|
||||||
|
|
||||||
const result = parseCalendarEvent(
|
const result = parseCalendarEvent(
|
||||||
rawData,
|
rawData,
|
||||||
baseColor,
|
baseColor,
|
||||||
calendarId,
|
calendar,
|
||||||
"/calendars/test.ics"
|
"/calendars/test.ics"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -319,12 +323,12 @@ describe("parseCalendarEvent", () => {
|
|||||||
["UID", {}, "text", "event-utc"],
|
["UID", {}, "text", "event-utc"],
|
||||||
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
||||||
["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"],
|
["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"],
|
||||||
] as unknown as [string, Record<string, string>, string, any];
|
] as VObjectProperty[];
|
||||||
|
|
||||||
const result = parseCalendarEvent(
|
const result = parseCalendarEvent(
|
||||||
rawData,
|
rawData,
|
||||||
baseColor,
|
baseColor,
|
||||||
calendarId,
|
calendar,
|
||||||
"/calendars/test.ics"
|
"/calendars/test.ics"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -337,12 +341,12 @@ describe("parseCalendarEvent", () => {
|
|||||||
["UID", {}, "text", "event-allday"],
|
["UID", {}, "text", "event-allday"],
|
||||||
["DTSTART", {}, "date", "2025-07-18"],
|
["DTSTART", {}, "date", "2025-07-18"],
|
||||||
["DTEND", {}, "date", "2025-07-19"],
|
["DTEND", {}, "date", "2025-07-19"],
|
||||||
] as unknown as [string, Record<string, string>, string, any];
|
] as VObjectProperty[];
|
||||||
|
|
||||||
const result = parseCalendarEvent(
|
const result = parseCalendarEvent(
|
||||||
rawData,
|
rawData,
|
||||||
baseColor,
|
baseColor,
|
||||||
calendarId,
|
calendar,
|
||||||
"/calendars/test.ics"
|
"/calendars/test.ics"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -391,8 +395,8 @@ describe("calendarEventToJCal", () => {
|
|||||||
expect(vevent[0]).toBe("vevent");
|
expect(vevent[0]).toBe("vevent");
|
||||||
|
|
||||||
const props = vevent[1];
|
const props = vevent[1];
|
||||||
const dtstart = props.find((p: any[]) => p[0] === "dtstart");
|
const dtstart = props.find((p: VObjectProperty) => p[0] === "dtstart");
|
||||||
const dtend = props.find((p: any[]) => p[0] === "dtend");
|
const dtend = props.find((p: VObjectProperty) => p[0] === "dtend");
|
||||||
|
|
||||||
expect(dtstart).toBeDefined();
|
expect(dtstart).toBeDefined();
|
||||||
expect(dtstart[1]).toEqual({ tzid: "Europe/Paris" });
|
expect(dtstart[1]).toEqual({ tzid: "Europe/Paris" });
|
||||||
@@ -474,7 +478,7 @@ describe("calendarEventToJCal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("converts with alarm included", () => {
|
it("converts with alarm included", () => {
|
||||||
const mockEvent: any = {
|
const mockEvent = {
|
||||||
uid: "event-10",
|
uid: "event-10",
|
||||||
title: "Alarm Event",
|
title: "Alarm Event",
|
||||||
start: "2025-07-20T07:00:00.000Z",
|
start: "2025-07-20T07:00:00.000Z",
|
||||||
@@ -483,7 +487,7 @@ describe("calendarEventToJCal", () => {
|
|||||||
allday: false,
|
allday: false,
|
||||||
alarm: { trigger: "-PT10M", action: "DISPLAY" },
|
alarm: { trigger: "-PT10M", action: "DISPLAY" },
|
||||||
attendee: [],
|
attendee: [],
|
||||||
};
|
} as unknown as CalendarEvent;
|
||||||
|
|
||||||
const result = calendarEventToJCal(mockEvent, "owner@example.com");
|
const result = calendarEventToJCal(mockEvent, "owner@example.com");
|
||||||
const vevent = result[2][0];
|
const vevent = result[2][0];
|
||||||
@@ -498,7 +502,7 @@ describe("calendarEventToJCal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("converts all-day events", () => {
|
it("converts all-day events", () => {
|
||||||
const mockEvent: any = {
|
const mockEvent = {
|
||||||
uid: "event-11",
|
uid: "event-11",
|
||||||
title: "All Day",
|
title: "All Day",
|
||||||
start: "2025-07-21T00:00:00.000Z",
|
start: "2025-07-21T00:00:00.000Z",
|
||||||
@@ -506,7 +510,7 @@ describe("calendarEventToJCal", () => {
|
|||||||
timezone: "Europe/Paris",
|
timezone: "Europe/Paris",
|
||||||
allday: true,
|
allday: true,
|
||||||
attendee: [],
|
attendee: [],
|
||||||
};
|
} as unknown as CalendarEvent;
|
||||||
|
|
||||||
const result = calendarEventToJCal(mockEvent);
|
const result = calendarEventToJCal(mockEvent);
|
||||||
const veventProps = result[2][0][1];
|
const veventProps = result[2][0][1];
|
||||||
@@ -843,7 +847,9 @@ describe("calendarEventToJCal", () => {
|
|||||||
const [vevent] = result[2];
|
const [vevent] = result[2];
|
||||||
const props = vevent[1];
|
const props = vevent[1];
|
||||||
|
|
||||||
const sequenceProp = props.find((p: any[]) => p[0] === "sequence");
|
const sequenceProp = props.find(
|
||||||
|
(p: VObjectProperty) => p[0] === "sequence"
|
||||||
|
);
|
||||||
expect(sequenceProp).toBeDefined();
|
expect(sequenceProp).toBeDefined();
|
||||||
expect(sequenceProp).toEqual(["sequence", {}, "integer", 3]);
|
expect(sequenceProp).toEqual(["sequence", {}, "integer", 3]);
|
||||||
});
|
});
|
||||||
@@ -864,7 +870,9 @@ describe("calendarEventToJCal", () => {
|
|||||||
const [vevent] = result[2];
|
const [vevent] = result[2];
|
||||||
const props = vevent[1];
|
const props = vevent[1];
|
||||||
|
|
||||||
const sequenceProp = props.find((p: any[]) => p[0] === "sequence");
|
const sequenceProp = props.find(
|
||||||
|
(p: VObjectProperty) => p[0] === "sequence"
|
||||||
|
);
|
||||||
expect(sequenceProp).toBeDefined();
|
expect(sequenceProp).toBeDefined();
|
||||||
expect(sequenceProp).toEqual(["sequence", {}, "integer", 1]);
|
expect(sequenceProp).toEqual(["sequence", {}, "integer", 1]);
|
||||||
});
|
});
|
||||||
@@ -887,7 +895,9 @@ describe("calendarEventToJCal", () => {
|
|||||||
const [vevent] = result[2];
|
const [vevent] = result[2];
|
||||||
const props = vevent[1];
|
const props = vevent[1];
|
||||||
|
|
||||||
const sequenceProp = props.find((p: any[]) => p[0] === "sequence");
|
const sequenceProp = props.find(
|
||||||
|
(p: VObjectProperty) => p[0] === "sequence"
|
||||||
|
);
|
||||||
expect(sequenceProp).toBeDefined();
|
expect(sequenceProp).toBeDefined();
|
||||||
expect(sequenceProp).toEqual(["sequence", {}, "integer", 2]);
|
expect(sequenceProp).toEqual(["sequence", {}, "integer", 2]);
|
||||||
});
|
});
|
||||||
@@ -910,18 +920,22 @@ describe("calendarEventToJCal", () => {
|
|||||||
const [vevent] = result[2];
|
const [vevent] = result[2];
|
||||||
const props = vevent[1];
|
const props = vevent[1];
|
||||||
|
|
||||||
const sequenceProp = props.find((p: any[]) => p[0] === "sequence");
|
const sequenceProp = props.find(
|
||||||
|
(p: VObjectProperty) => p[0] === "sequence"
|
||||||
|
);
|
||||||
expect(sequenceProp).toBeDefined();
|
expect(sequenceProp).toBeDefined();
|
||||||
expect(sequenceProp).toEqual(["sequence", {}, "integer", 5]);
|
expect(sequenceProp).toEqual(["sequence", {}, "integer", 5]);
|
||||||
|
|
||||||
// Also verify recurrence-id is present
|
// Also verify recurrence-id is present
|
||||||
const recurrenceIdProp = props.find((p: any[]) => p[0] === "recurrence-id");
|
const recurrenceIdProp = props.find(
|
||||||
|
(p: VObjectProperty) => p[0] === "recurrence-id"
|
||||||
|
);
|
||||||
expect(recurrenceIdProp).toBeDefined();
|
expect(recurrenceIdProp).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("combineMasterDateWithFormTime", () => {
|
describe("combineMasterDateWithFormTime", () => {
|
||||||
const mockFormatDateTime = (iso: string, tz: string) => {
|
const mockFormatDateTime = (iso: string) => {
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
const year = date.getUTCFullYear();
|
const year = date.getUTCFullYear();
|
||||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||||
@@ -1127,12 +1141,6 @@ describe("normalizeTimezone", () => {
|
|||||||
|
|
||||||
describe("detectRecurringEventChanges", () => {
|
describe("detectRecurringEventChanges", () => {
|
||||||
const mockResolveTimezone = (tz: string) => tz;
|
const mockResolveTimezone = (tz: string) => tz;
|
||||||
const mockFormatDateTime = (iso: string, tz: string) => {
|
|
||||||
const date = new Date(iso);
|
|
||||||
const hour = String(date.getUTCHours()).padStart(2, "0");
|
|
||||||
const minute = String(date.getUTCMinutes()).padStart(2, "0");
|
|
||||||
return `2025-10-14T${hour}:${minute}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
it("should detect when only time changes", () => {
|
it("should detect when only time changes", () => {
|
||||||
const oldEvent = {
|
const oldEvent = {
|
||||||
@@ -1346,3 +1354,69 @@ describe("detectRecurringEventChanges", () => {
|
|||||||
expect(result.repetitionRulesChanged).toBe(true);
|
expect(result.repetitionRulesChanged).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseCalendarEvent - delegated calendar URL handling", () => {
|
||||||
|
const baseColor = { light: "#00FF00" };
|
||||||
|
const rawData: VObjectProperty[] = [
|
||||||
|
["uid", {}, "text", "event-uid-123"],
|
||||||
|
["dtstart", {}, "date-time", "20240115T100000Z"],
|
||||||
|
["dtend", {}, "date-time", "20240115T110000Z"],
|
||||||
|
["summary", {}, "text", "Test Event"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const eventURL = "/calendars/user2/cal1/event-uid-123.ics";
|
||||||
|
|
||||||
|
describe("non-delegated calendar", () => {
|
||||||
|
const calendar = {
|
||||||
|
id: "user1/cal1",
|
||||||
|
delegated: false,
|
||||||
|
link: "/calendars/user1/cal1.json",
|
||||||
|
} as Calendar;
|
||||||
|
|
||||||
|
it("uses the raw eventURL as-is", () => {
|
||||||
|
const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL);
|
||||||
|
expect(result.URL).toBe(eventURL);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("delegated calendar", () => {
|
||||||
|
const calendar = {
|
||||||
|
id: "user2/cal1",
|
||||||
|
delegated: true,
|
||||||
|
link: "/calendars/user2/cal1.json",
|
||||||
|
owner: { emails: ["owner@example.com"] },
|
||||||
|
} as unknown as Calendar;
|
||||||
|
|
||||||
|
it("rewrites URL using calendar link base path", () => {
|
||||||
|
const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL);
|
||||||
|
expect(result.URL).toBe("/calendars/user2/cal1/event-uid-123.ics");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the event filename from the original URL", () => {
|
||||||
|
const differentSourceURL = "/calendars/someother/path/event-uid-123.ics";
|
||||||
|
const result = parseCalendarEvent(
|
||||||
|
rawData,
|
||||||
|
baseColor,
|
||||||
|
calendar,
|
||||||
|
differentSourceURL
|
||||||
|
);
|
||||||
|
// filename is extracted and rebased onto calendar link
|
||||||
|
expect(result.URL).toMatch(
|
||||||
|
/\/calendars\/user2\/cal1\/event-uid-123\.ics$/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still sets calId from calendar.id", () => {
|
||||||
|
const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL);
|
||||||
|
expect(result.calId).toBe("user2/cal1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("empty calendar object (used in deleteEventInstance)", () => {
|
||||||
|
it("does not throw and sets URL to empty string", () => {
|
||||||
|
const result = parseCalendarEvent(rawData, baseColor, {} as Calendar, "");
|
||||||
|
expect(result.URL).toBe("");
|
||||||
|
expect(result.calId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe("EventSearchBar", () => {
|
|||||||
name: "Calendar personal",
|
name: "Calendar personal",
|
||||||
id: "user1/cal1",
|
id: "user1/cal1",
|
||||||
color: { light: "#FF0000", dark: "#000" },
|
color: { light: "#FF0000", dark: "#000" },
|
||||||
ownerEmails: ["alice@example.com"],
|
owner: { emails: ["alice@example.com"] },
|
||||||
events: {
|
events: {
|
||||||
event1: {
|
event1: {
|
||||||
id: "event1",
|
id: "event1",
|
||||||
@@ -58,7 +58,7 @@ describe("EventSearchBar", () => {
|
|||||||
delegated: true,
|
delegated: true,
|
||||||
id: "user2/cal1",
|
id: "user2/cal1",
|
||||||
color: { light: "#FF0000", dark: "#000" },
|
color: { light: "#FF0000", dark: "#000" },
|
||||||
ownerEmails: ["alice@example.com"],
|
owner: { emails: ["alice@example.com"] },
|
||||||
events: {
|
events: {
|
||||||
event1: {
|
event1: {
|
||||||
id: "event1",
|
id: "event1",
|
||||||
@@ -89,7 +89,7 @@ describe("EventSearchBar", () => {
|
|||||||
name: "Calendar shared",
|
name: "Calendar shared",
|
||||||
id: "user3/cal1",
|
id: "user3/cal1",
|
||||||
color: { light: "#FF0000", dark: "#000" },
|
color: { light: "#FF0000", dark: "#000" },
|
||||||
ownerEmails: ["alice@example.com"],
|
owner: { emails: ["alice@example.com"] },
|
||||||
events: {
|
events: {
|
||||||
event1: {
|
event1: {
|
||||||
id: "event1",
|
id: "event1",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("SearchResultsPage", () => {
|
|||||||
name: "Calendar personal",
|
name: "Calendar personal",
|
||||||
id: "user1/cal1",
|
id: "user1/cal1",
|
||||||
color: { light: "#FF0000", dark: "#000" },
|
color: { light: "#FF0000", dark: "#000" },
|
||||||
ownerEmails: ["alice@example.com"],
|
owner: { emails: ["alice@example.com"] },
|
||||||
events: {
|
events: {
|
||||||
event1: {
|
event1: {
|
||||||
id: "event1",
|
id: "event1",
|
||||||
@@ -56,7 +56,7 @@ describe("SearchResultsPage", () => {
|
|||||||
delegated: true,
|
delegated: true,
|
||||||
id: "user2/cal1",
|
id: "user2/cal1",
|
||||||
color: { light: "#FF0000", dark: "#000" },
|
color: { light: "#FF0000", dark: "#000" },
|
||||||
ownerEmails: ["alice@example.com"],
|
owner: { emails: ["alice@example.com"] },
|
||||||
events: {
|
events: {
|
||||||
event1: {
|
event1: {
|
||||||
id: "event1",
|
id: "event1",
|
||||||
@@ -87,7 +87,7 @@ describe("SearchResultsPage", () => {
|
|||||||
name: "Calendar shared",
|
name: "Calendar shared",
|
||||||
id: "user3/cal1",
|
id: "user3/cal1",
|
||||||
color: { light: "#FF0000", dark: "#000" },
|
color: { light: "#FF0000", dark: "#000" },
|
||||||
ownerEmails: ["alice@example.com"],
|
owner: { emails: ["alice@example.com"] },
|
||||||
events: {
|
events: {
|
||||||
event1: {
|
event1: {
|
||||||
id: "event1",
|
id: "event1",
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { buildDelegatedEventURL } from "@/features/Events/eventUtils";
|
||||||
|
import { Calendar } from "@/features/Calendars/CalendarTypes";
|
||||||
|
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||||
|
|
||||||
|
const makeCalendar = (link: string): Calendar =>
|
||||||
|
({
|
||||||
|
id: "user2/cal1",
|
||||||
|
delegated: true,
|
||||||
|
link,
|
||||||
|
owner: { emails: ["owner@example.com"] },
|
||||||
|
}) as Calendar;
|
||||||
|
|
||||||
|
const makeEvent = (url: string): CalendarEvent =>
|
||||||
|
({ URL: url, calId: "user2/cal1" }) as CalendarEvent;
|
||||||
|
|
||||||
|
describe("buildDelegatedEventURL", () => {
|
||||||
|
it("rebases event filename onto calendar link base path", () => {
|
||||||
|
const calendar = makeCalendar("/calendars/user2/cal1.json");
|
||||||
|
const event = makeEvent("/calendars/someother/path/event-abc.ics");
|
||||||
|
expect(buildDelegatedEventURL(calendar, event)).toBe(
|
||||||
|
"/calendars/user2/cal1/event-abc.ics"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips .json suffix from calendar link", () => {
|
||||||
|
const calendar = makeCalendar("/calendars/user2/cal1.json");
|
||||||
|
const event = makeEvent("/calendars/user2/cal1/event-abc.ics");
|
||||||
|
const result = buildDelegatedEventURL(calendar, event);
|
||||||
|
expect(result).not.toContain(".json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the exact filename from the event URL", () => {
|
||||||
|
const calendar = makeCalendar("/calendars/user2/cal1.json");
|
||||||
|
const event = makeEvent("/calendars/user2/cal1/some-uid-with-dashes.ics");
|
||||||
|
expect(buildDelegatedEventURL(calendar, event)).toBe(
|
||||||
|
"/calendars/user2/cal1/some-uid-with-dashes.ics"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when event URL has no filename", () => {
|
||||||
|
const calendar = makeCalendar("/calendars/user2/cal1.json");
|
||||||
|
const event = makeEvent("");
|
||||||
|
expect(() => buildDelegatedEventURL(calendar, event)).toThrow(
|
||||||
|
/Cannot extract filename from event URL/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works with nested calendar link paths", () => {
|
||||||
|
const calendar = makeCalendar("/dav/calendars/users/user2/cal1.json");
|
||||||
|
const event = makeEvent("/other/path/event-xyz.ics");
|
||||||
|
expect(buildDelegatedEventURL(calendar, event)).toBe(
|
||||||
|
"/dav/calendars/users/user2/cal1/event-xyz.ics"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { getCalendarVisibility } from "@/components/Calendar/utils/calendarUtils";
|
import {
|
||||||
|
eventToFullCalendarFormat,
|
||||||
|
getCalendarVisibility,
|
||||||
|
} from "@/components/Calendar/utils/calendarUtils";
|
||||||
|
import { Calendar, DelegationAccess } from "@/features/Calendars/CalendarTypes";
|
||||||
import { AclEntry } from "@/features/Calendars/types/CalendarData";
|
import { AclEntry } from "@/features/Calendars/types/CalendarData";
|
||||||
|
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||||
|
import { userOrganiser } from "@/features/User/userDataTypes";
|
||||||
|
|
||||||
describe("getCalendarVisibility", () => {
|
describe("getCalendarVisibility", () => {
|
||||||
it("returns 'public' when {DAV:}authenticated has {DAV:}read", () => {
|
it("returns 'public' when {DAV:}authenticated has {DAV:}read", () => {
|
||||||
@@ -67,3 +73,159 @@ describe("getCalendarVisibility", () => {
|
|||||||
expect(getCalendarVisibility(acl)).toBe("public");
|
expect(getCalendarVisibility(acl)).toBe("public");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jest.mock("twake-i18n", () => ({
|
||||||
|
useI18n: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseEvent: CalendarEvent = {
|
||||||
|
uid: "event-1",
|
||||||
|
calId: "user1/cal1",
|
||||||
|
title: "Test Event",
|
||||||
|
start: "2024-01-15T10:00:00",
|
||||||
|
end: "2024-01-15T11:00:00",
|
||||||
|
allday: false,
|
||||||
|
organizer: { cal_address: "owner@example.com" } as userOrganiser,
|
||||||
|
color: { light: "#FF0000" },
|
||||||
|
class: "PUBLIC",
|
||||||
|
} as unknown as CalendarEvent;
|
||||||
|
|
||||||
|
const makeCalendar = (overrides: Partial<Calendar> = {}): Calendar =>
|
||||||
|
({
|
||||||
|
id: "user1/cal1",
|
||||||
|
name: "Test Calendar",
|
||||||
|
delegated: false,
|
||||||
|
access: {} as DelegationAccess,
|
||||||
|
owner: { emails: ["alice@example.com"], firstname: "Alice" },
|
||||||
|
color: { light: "#FF0000" },
|
||||||
|
events: {},
|
||||||
|
...overrides,
|
||||||
|
}) as Calendar;
|
||||||
|
|
||||||
|
const callFormat = (
|
||||||
|
event: CalendarEvent,
|
||||||
|
calendars: Record<string, Calendar>,
|
||||||
|
userAddress = "alice@example.com",
|
||||||
|
userId = "user1"
|
||||||
|
) =>
|
||||||
|
eventToFullCalendarFormat([event], [], userId, userAddress, false, calendars);
|
||||||
|
|
||||||
|
describe("eventToFullCalendarFormat - editable flag", () => {
|
||||||
|
describe("personal calendar (non-delegated)", () => {
|
||||||
|
const calendar = makeCalendar({ delegated: false });
|
||||||
|
|
||||||
|
it("is editable when user is organizer", () => {
|
||||||
|
const event = {
|
||||||
|
...baseEvent,
|
||||||
|
organizer: { cal_address: "alice@example.com" },
|
||||||
|
} as CalendarEvent;
|
||||||
|
const [result] = callFormat(event, { "user1/cal1": calendar });
|
||||||
|
expect(result.editable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not editable when user is not organizer", () => {
|
||||||
|
const event = {
|
||||||
|
...baseEvent,
|
||||||
|
organizer: { cal_address: "other@example.com" },
|
||||||
|
} as CalendarEvent;
|
||||||
|
const [result] = callFormat(event, { "user1/cal1": calendar });
|
||||||
|
expect(result.editable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not editable when pending", () => {
|
||||||
|
const event = {
|
||||||
|
...baseEvent,
|
||||||
|
organizer: { cal_address: "alice@example.com" },
|
||||||
|
} as CalendarEvent;
|
||||||
|
const [result] = eventToFullCalendarFormat(
|
||||||
|
[event],
|
||||||
|
[],
|
||||||
|
"user1",
|
||||||
|
"alice@example.com",
|
||||||
|
true,
|
||||||
|
{ "user1/cal1": calendar }
|
||||||
|
);
|
||||||
|
expect(result.editable).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("delegated calendar", () => {
|
||||||
|
const writeDelegatedCalendar = makeCalendar({
|
||||||
|
id: "user2/cal1",
|
||||||
|
delegated: true,
|
||||||
|
access: { write: true } as DelegationAccess,
|
||||||
|
owner: { emails: ["owner@example.com"], firstname: "Owner" },
|
||||||
|
});
|
||||||
|
const readDelegatedCalendar = makeCalendar({
|
||||||
|
id: "user2/cal1",
|
||||||
|
delegated: true,
|
||||||
|
access: { read: true } as DelegationAccess,
|
||||||
|
owner: { emails: ["owner@example.com"], firstname: "Owner" },
|
||||||
|
});
|
||||||
|
const event = { ...baseEvent, calId: "user2/cal1" };
|
||||||
|
|
||||||
|
it("is editable when owner is organizer and delegated user has write rights", () => {
|
||||||
|
const [result] = callFormat(
|
||||||
|
{
|
||||||
|
...event,
|
||||||
|
organizer: { cal_address: "owner@example.com" },
|
||||||
|
} as CalendarEvent,
|
||||||
|
{ "user2/cal1": writeDelegatedCalendar }
|
||||||
|
);
|
||||||
|
expect(result.editable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not editable when owner is organizer but delegated user only has read access", () => {
|
||||||
|
const [result] = callFormat(
|
||||||
|
{
|
||||||
|
...event,
|
||||||
|
organizer: { cal_address: "owner@example.com" },
|
||||||
|
} as CalendarEvent,
|
||||||
|
{ "user2/cal1": readDelegatedCalendar }
|
||||||
|
);
|
||||||
|
expect(result.editable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not editable when owner is not organizer", () => {
|
||||||
|
const [result] = callFormat(
|
||||||
|
{
|
||||||
|
...event,
|
||||||
|
organizer: { cal_address: "someone-else@example.com" },
|
||||||
|
} as CalendarEvent,
|
||||||
|
{ "user2/cal1": writeDelegatedCalendar }
|
||||||
|
);
|
||||||
|
expect(result.editable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not editable when pending even if owner is organizer", () => {
|
||||||
|
const [result] = eventToFullCalendarFormat(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...event,
|
||||||
|
organizer: { cal_address: "owner@example.com" },
|
||||||
|
} as CalendarEvent,
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
"user1",
|
||||||
|
"alice@example.com",
|
||||||
|
true,
|
||||||
|
{ "user2/cal1": writeDelegatedCalendar }
|
||||||
|
);
|
||||||
|
expect(result.editable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses owner email not logged-in user email for organizer check", () => {
|
||||||
|
// logged-in user is alice, owner is owner@example.com
|
||||||
|
// organizer matches owner → editable
|
||||||
|
const [result] = callFormat(
|
||||||
|
{
|
||||||
|
...event,
|
||||||
|
organizer: { cal_address: "owner@example.com" },
|
||||||
|
} as CalendarEvent,
|
||||||
|
{ "user2/cal1": writeDelegatedCalendar },
|
||||||
|
"alice@example.com" // logged-in user, should NOT be used for delegated
|
||||||
|
);
|
||||||
|
expect(result.editable).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { createEventContext } from "@/features/Events/createEventContext";
|
||||||
|
import { Calendar } from "@/features/Calendars/CalendarTypes";
|
||||||
|
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||||
|
import { userData } from "@/features/User/userDataTypes";
|
||||||
|
import { userAttendee } from "@/features/User/models/attendee";
|
||||||
|
|
||||||
|
const makeUser = (email: string): userData => ({
|
||||||
|
email,
|
||||||
|
given_name: "Alice",
|
||||||
|
family_name: "User",
|
||||||
|
name: "Alice User",
|
||||||
|
sid: "user1",
|
||||||
|
sub: "user1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeEvent = (overrides: Partial<CalendarEvent> = {}): CalendarEvent =>
|
||||||
|
({
|
||||||
|
uid: "event-1",
|
||||||
|
calId: "user1/cal1",
|
||||||
|
title: "Test Event",
|
||||||
|
start: "2024-01-15T10:00:00",
|
||||||
|
end: "2024-01-15T11:00:00",
|
||||||
|
organizer: { cal_address: "alice@example.com" },
|
||||||
|
attendee: [
|
||||||
|
{ cal_address: "alice@example.com", partstat: "ACCEPTED" },
|
||||||
|
{ cal_address: "owner@example.com", partstat: "ACCEPTED" },
|
||||||
|
{ cal_address: "other@example.com", partstat: "NEEDS-ACTION" },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
}) as CalendarEvent;
|
||||||
|
|
||||||
|
const makeCalendar = (overrides: Partial<Calendar> = {}): Calendar =>
|
||||||
|
({
|
||||||
|
id: "user1/cal1",
|
||||||
|
name: "Test Calendar",
|
||||||
|
delegated: false,
|
||||||
|
owner: { emails: ["alice@example.com"], firstname: "Alice" },
|
||||||
|
events: {},
|
||||||
|
...overrides,
|
||||||
|
}) as Calendar;
|
||||||
|
|
||||||
|
describe("createEventContext", () => {
|
||||||
|
describe("non-delegated calendar", () => {
|
||||||
|
const calendar = makeCalendar({ delegated: false });
|
||||||
|
const user = makeUser("alice@example.com");
|
||||||
|
|
||||||
|
it("finds currentUserAttendee by logged-in user email", () => {
|
||||||
|
const event = makeEvent();
|
||||||
|
const ctx = createEventContext(event, calendar, user);
|
||||||
|
expect(ctx.currentUserAttendee?.cal_address).toBe("alice@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null currentUserAttendee when user is not an attendee", () => {
|
||||||
|
const event = makeEvent({ attendee: [] });
|
||||||
|
const ctx = createEventContext(event, calendar, user);
|
||||||
|
expect(ctx.currentUserAttendee).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isOwn is true when user email is in owner emails", () => {
|
||||||
|
const ctx = createEventContext(makeEvent(), calendar, user);
|
||||||
|
expect(ctx.isOwn).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isOwn is false when user email is not in owner emails", () => {
|
||||||
|
const other = makeUser("other@example.com");
|
||||||
|
const ctx = createEventContext(makeEvent(), calendar, other);
|
||||||
|
expect(ctx.isOwn).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("delegated calendar", () => {
|
||||||
|
const calendar = makeCalendar({
|
||||||
|
id: "user2/cal1",
|
||||||
|
delegated: true,
|
||||||
|
owner: { emails: ["owner@example.com"], firstname: "Owner" },
|
||||||
|
});
|
||||||
|
const user = makeUser("alice@example.com"); // logged-in user, not the owner
|
||||||
|
|
||||||
|
it("finds currentUserAttendee by owner email, not logged-in user email", () => {
|
||||||
|
const event = makeEvent({ calId: "user2/cal1" });
|
||||||
|
const ctx = createEventContext(event, calendar, user);
|
||||||
|
expect(ctx.currentUserAttendee?.cal_address).toBe("owner@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not find logged-in user as attendee for delegated calendar", () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
calId: "user2/cal1",
|
||||||
|
attendee: [
|
||||||
|
{
|
||||||
|
cal_address: "owner@example.com",
|
||||||
|
partstat: "ACCEPTED",
|
||||||
|
} as userAttendee,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const ctx = createEventContext(event, calendar, user);
|
||||||
|
// currentUserAttendee should be owner's, not alice's
|
||||||
|
expect(ctx.currentUserAttendee?.cal_address).toBe("owner@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined currentUserAttendee when owner is not an attendee", () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
calId: "user2/cal1",
|
||||||
|
attendee: [
|
||||||
|
{
|
||||||
|
cal_address: "alice@example.com",
|
||||||
|
partstat: "ACCEPTED",
|
||||||
|
} as userAttendee,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const ctx = createEventContext(event, calendar, user);
|
||||||
|
expect(ctx.currentUserAttendee).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes event through unchanged", () => {
|
||||||
|
const event = makeEvent({ calId: "user2/cal1" });
|
||||||
|
const ctx = createEventContext(event, calendar, user);
|
||||||
|
expect(ctx.event).toBe(event);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -697,7 +697,8 @@ export default function CalendarApp({
|
|||||||
filteredTempEvents,
|
filteredTempEvents,
|
||||||
userId,
|
userId,
|
||||||
userData?.email,
|
userData?.email,
|
||||||
isPending
|
isPending,
|
||||||
|
calendars
|
||||||
)}
|
)}
|
||||||
eventOrder={(a: EventApi, b: EventApi) =>
|
eventOrder={(a: EventApi, b: EventApi) =>
|
||||||
a.extendedProps.priority - b.extendedProps.priority
|
a.extendedProps.priority - b.extendedProps.priority
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ function getCalendarsFromUsersDelta(
|
|||||||
function buildEmailToCalendarMap(calRecord: Record<string, Calendar>) {
|
function buildEmailToCalendarMap(calRecord: Record<string, Calendar>) {
|
||||||
const map = new Map<string, string[]>();
|
const map = new Map<string, string[]>();
|
||||||
for (const [id, cal] of Object.entries(calRecord)) {
|
for (const [id, cal] of Object.entries(calRecord)) {
|
||||||
cal.ownerEmails?.forEach((email) => {
|
cal.owner?.emails?.forEach((email) => {
|
||||||
const existing = map.get(email);
|
const existing = map.get(email);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.push(id);
|
existing.push(id);
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
|
|||||||
const attendees = arg.event._def.extendedProps.attendee || [];
|
const attendees = arg.event._def.extendedProps.attendee || [];
|
||||||
if (!calendars[arg.event._def.extendedProps.calId]) return;
|
if (!calendars[arg.event._def.extendedProps.calId]) return;
|
||||||
const ownerEmails = new Set(
|
const ownerEmails = new Set(
|
||||||
calendars[arg.event._def.extendedProps.calId].ownerEmails?.map((email) =>
|
calendars[arg.event._def.extendedProps.calId].owner?.emails?.map(
|
||||||
email.toLowerCase()
|
(email) => email.toLowerCase()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const showSpecialDisplay = attendees.filter((att: userAttendee) =>
|
const showSpecialDisplay = attendees.filter((att: userAttendee) =>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { AppDispatch } from "@/app/store";
|
import { AppDispatch } from "@/app/store";
|
||||||
import { Calendar } from "@/features/Calendars/CalendarTypes";
|
import { Calendar, DelegationAccess } from "@/features/Calendars/CalendarTypes";
|
||||||
import { getCalendarDetailAsync } from "@/features/Calendars/services";
|
import { getCalendarDetailAsync } from "@/features/Calendars/services";
|
||||||
import { AclEntry } from "@/features/Calendars/types/CalendarData";
|
import { AclEntry } from "@/features/Calendars/types/CalendarData";
|
||||||
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||||
import { formatDateToYYYYMMDDTHHMMSS } from "@/utils/dateUtils";
|
import { formatDateToYYYYMMDDTHHMMSS } from "@/utils/dateUtils";
|
||||||
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
|
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
|
||||||
|
import { getEffectiveEmail } from "@/utils/getEffectiveEmail";
|
||||||
|
import { isEventOrganiser } from "@/utils/isEventOrganiser";
|
||||||
import { convertEventDateTimeToISO } from "@/utils/timezone";
|
import { convertEventDateTimeToISO } from "@/utils/timezone";
|
||||||
import { SlotLabelContentArg } from "@fullcalendar/core";
|
import { EventInput, SlotLabelContentArg } from "@fullcalendar/core";
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
import { useI18n } from "twake-i18n";
|
import { useI18n } from "twake-i18n";
|
||||||
|
|
||||||
@@ -68,56 +70,91 @@ export function formatEventChipTitle(
|
|||||||
: e.title;
|
: e.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConvertedEvent = CalendarEvent & {
|
||||||
|
colors: Record<string, string> | undefined;
|
||||||
|
editable: boolean;
|
||||||
|
priority: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyTimezoneToEvent(
|
||||||
|
event: CalendarEvent,
|
||||||
|
convertedEvent: ConvertedEvent
|
||||||
|
): void {
|
||||||
|
const eventTimezone = event.timezone || "Etc/UTC";
|
||||||
|
const isAllDay = event.allday ?? false;
|
||||||
|
|
||||||
|
if (!isAllDay && event.start) {
|
||||||
|
const startISO = convertEventDateTimeToISO(event.start, eventTimezone, {
|
||||||
|
isAllDay,
|
||||||
|
});
|
||||||
|
if (startISO) convertedEvent.start = startISO;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAllDay && event.end && eventTimezone) {
|
||||||
|
const endISO = convertEventDateTimeToISO(event.end, eventTimezone, {
|
||||||
|
isAllDay,
|
||||||
|
});
|
||||||
|
if (endISO) convertedEvent.end = endISO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConvertedEvent(
|
||||||
|
event: CalendarEvent,
|
||||||
|
calendar: Calendar | undefined,
|
||||||
|
userId: string | undefined,
|
||||||
|
userAddress: string | undefined,
|
||||||
|
pending: boolean,
|
||||||
|
t: (key: string) => string
|
||||||
|
): ConvertedEvent {
|
||||||
|
const isWriteDelegated =
|
||||||
|
(calendar?.delegated &&
|
||||||
|
calendar.access?.write &&
|
||||||
|
(!event.class || event.class === "PUBLIC")) ??
|
||||||
|
false;
|
||||||
|
|
||||||
|
const effectiveEmail = getEffectiveEmail(
|
||||||
|
calendar,
|
||||||
|
isWriteDelegated,
|
||||||
|
userAddress
|
||||||
|
);
|
||||||
|
const isOrganiser = isEventOrganiser(event, effectiveEmail);
|
||||||
|
const isPersonalEvent = extractEventBaseUuid(event.calId) === userId;
|
||||||
|
|
||||||
|
const convertedEvent: ConvertedEvent = {
|
||||||
|
...event,
|
||||||
|
title: formatEventChipTitle(event, t),
|
||||||
|
colors: event.color,
|
||||||
|
editable: (isPersonalEvent || isWriteDelegated) && isOrganiser && !pending,
|
||||||
|
priority: isPersonalEvent ? 1 : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
applyTimezoneToEvent(event, convertedEvent);
|
||||||
|
|
||||||
|
return convertedEvent;
|
||||||
|
}
|
||||||
|
|
||||||
export const eventToFullCalendarFormat = (
|
export const eventToFullCalendarFormat = (
|
||||||
filteredEvents: CalendarEvent[],
|
filteredEvents: CalendarEvent[],
|
||||||
filteredTempEvents: CalendarEvent[],
|
filteredTempEvents: CalendarEvent[],
|
||||||
userId: string | undefined,
|
userId: string | undefined,
|
||||||
userAddress: string | undefined,
|
userAddress: string | undefined,
|
||||||
pending: boolean
|
pending: boolean,
|
||||||
) => {
|
calendars: Record<string, Calendar>
|
||||||
|
): EventInput[] => {
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return filteredEvents
|
return filteredEvents
|
||||||
.concat(filteredTempEvents.map((e) => ({ ...e, temp: true })))
|
.concat(filteredTempEvents.map((event) => ({ ...event, temp: true })))
|
||||||
.map((e) => {
|
.map((event) =>
|
||||||
const eventTimezone = e.timezone || "Etc/UTC";
|
buildConvertedEvent(
|
||||||
const isAllDay = e.allday ?? false;
|
event,
|
||||||
const isOrganiser = e.organizer
|
calendars[event.calId],
|
||||||
? e.organizer.cal_address?.toLowerCase() === userAddress?.toLowerCase()
|
userId,
|
||||||
: true; // if there are no organizer in the event we assume it was organized by the owner
|
userAddress,
|
||||||
const isPersonnalEvent = extractEventBaseUuid(e.calId) === userId;
|
pending,
|
||||||
const convertedEvent: CalendarEvent & {
|
t
|
||||||
colors: Record<string, string> | undefined;
|
)
|
||||||
editable: boolean;
|
) as EventInput[];
|
||||||
priority: number;
|
|
||||||
} = {
|
|
||||||
...e,
|
|
||||||
title: formatEventChipTitle(e, t),
|
|
||||||
colors: e.color,
|
|
||||||
editable: isPersonnalEvent && isOrganiser && !pending,
|
|
||||||
priority: isPersonnalEvent ? 1 : 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isAllDay && e.start && eventTimezone) {
|
|
||||||
const startISO = convertEventDateTimeToISO(e.start, eventTimezone, {
|
|
||||||
isAllDay,
|
|
||||||
});
|
|
||||||
if (startISO) {
|
|
||||||
convertedEvent.start = startISO;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAllDay && e.end && eventTimezone) {
|
|
||||||
const endISO = convertEventDateTimeToISO(e.end, eventTimezone, {
|
|
||||||
isAllDay,
|
|
||||||
});
|
|
||||||
if (endISO) {
|
|
||||||
convertedEvent.end = endISO;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return convertedEvent;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const extractEvents = (
|
export const extractEvents = (
|
||||||
@@ -232,7 +269,6 @@ export const updateCalsDetails = (
|
|||||||
|
|
||||||
export function getCalendarVisibility(acl: AclEntry[]): "private" | "public" {
|
export function getCalendarVisibility(acl: AclEntry[]): "private" | "public" {
|
||||||
let hasRead = false;
|
let hasRead = false;
|
||||||
// const hasFreeBusy = false;
|
|
||||||
if (acl) {
|
if (acl) {
|
||||||
for (const entry of acl) {
|
for (const entry of acl) {
|
||||||
if (entry.principal !== "{DAV:}authenticated") continue;
|
if (entry.principal !== "{DAV:}authenticated") continue;
|
||||||
@@ -246,3 +282,52 @@ export function getCalendarVisibility(acl: AclEntry[]): "private" | "public" {
|
|||||||
if (hasRead) return "public";
|
if (hasRead) return "public";
|
||||||
return "private";
|
return "private";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCalendarDelegationAccess(
|
||||||
|
acl: AclEntry[],
|
||||||
|
userId: string
|
||||||
|
): DelegationAccess {
|
||||||
|
const userPrincipal = `principals/users/${userId}`;
|
||||||
|
const access: DelegationAccess = {
|
||||||
|
freebusy: false,
|
||||||
|
read: false,
|
||||||
|
write: false,
|
||||||
|
"write-properties": false,
|
||||||
|
all: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const entry of acl ?? []) {
|
||||||
|
if (entry.principal !== userPrincipal) continue;
|
||||||
|
privilegeToAccess(entry.privilege, access);
|
||||||
|
}
|
||||||
|
|
||||||
|
return access;
|
||||||
|
}
|
||||||
|
|
||||||
|
function privilegeToAccess(privilege: string, currentAccess: DelegationAccess) {
|
||||||
|
switch (privilege) {
|
||||||
|
case "{urn:ietf:params:xml:ns:caldav}read-free-busy":
|
||||||
|
currentAccess["freebusy"] = true;
|
||||||
|
break;
|
||||||
|
case "{DAV:}read":
|
||||||
|
currentAccess["read"] = true;
|
||||||
|
currentAccess["freebusy"] = true; // read implies read-free-busy
|
||||||
|
break;
|
||||||
|
case "{DAV:}write-properties":
|
||||||
|
currentAccess["write-properties"] = true;
|
||||||
|
break;
|
||||||
|
case "{DAV:}write":
|
||||||
|
currentAccess["write-properties"] = true; // write implies write-properties
|
||||||
|
currentAccess["write"] = true;
|
||||||
|
break;
|
||||||
|
case "{DAV:}all":
|
||||||
|
currentAccess["freebusy"] = true;
|
||||||
|
currentAccess["read"] = true;
|
||||||
|
currentAccess["write-properties"] = true;
|
||||||
|
currentAccess["write"] = true;
|
||||||
|
currentAccess["all"] = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function EventChip({
|
|||||||
// Event properties
|
// Event properties
|
||||||
const isPrivate = PRIVATE_CLASSIFICATIONS.includes(classification);
|
const isPrivate = PRIVATE_CLASSIFICATIONS.includes(classification);
|
||||||
const ownerEmails = new Set(
|
const ownerEmails = new Set(
|
||||||
calendar.ownerEmails?.map((e) => e.toLowerCase())
|
calendar.owner?.emails?.map((e) => e.toLowerCase())
|
||||||
);
|
);
|
||||||
// const delegated = calendar.delegated;
|
// const delegated = calendar.delegated;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { PartStat } from "@/features/User/models/attendee";
|
|||||||
import { createAttendee } from "@/features/User/models/attendee.mapper";
|
import { createAttendee } from "@/features/User/models/attendee.mapper";
|
||||||
import { userData } from "@/features/User/userDataTypes";
|
import { userData } from "@/features/User/userDataTypes";
|
||||||
import { buildFamilyName } from "@/utils/buildFamilyName";
|
import { buildFamilyName } from "@/utils/buildFamilyName";
|
||||||
|
import { isEventOrganiser } from "@/utils/isEventOrganiser";
|
||||||
|
|
||||||
function updateEventAttendees(
|
function updateEventAttendees(
|
||||||
event: CalendarEvent,
|
event: CalendarEvent,
|
||||||
@@ -23,9 +24,7 @@ function updateEventAttendees(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const eventHasNoAttendees = !event?.attendee || event.attendee.length === 0;
|
const eventHasNoAttendees = !event?.attendee || event.attendee.length === 0;
|
||||||
const isOrganizer =
|
const isOrganizer = isEventOrganiser(event, user.email);
|
||||||
!event.organizer ||
|
|
||||||
event.organizer.cal_address?.toLowerCase() === user.email?.toLowerCase();
|
|
||||||
if (eventHasNoAttendees) {
|
if (eventHasNoAttendees) {
|
||||||
const userdata = createAttendee({
|
const userdata = createAttendee({
|
||||||
cal_address: user.email,
|
cal_address: user.email,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CalendarInput, CalendarList } from "./types/CalendarData";
|
|||||||
|
|
||||||
export async function getCalendars(
|
export async function getCalendars(
|
||||||
userId: string,
|
userId: string,
|
||||||
scope: string = "personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&",
|
scope: string = "personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&withRights=true",
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<CalendarList> {
|
): Promise<CalendarList> {
|
||||||
const calendars = await api
|
const calendars = await api
|
||||||
|
|||||||
@@ -251,7 +251,6 @@ const CalendarSlice = createSlice({
|
|||||||
description: action.payload.desc,
|
description: action.payload.desc,
|
||||||
name: action.payload.name,
|
name: action.payload.name,
|
||||||
owner: action.payload.owner,
|
owner: action.payload.owner,
|
||||||
ownerEmails: action.payload.ownerEmails,
|
|
||||||
events: {},
|
events: {},
|
||||||
} as Calendar;
|
} as Calendar;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
@@ -270,7 +269,6 @@ const CalendarSlice = createSlice({
|
|||||||
name: action.payload.name,
|
name: action.payload.name,
|
||||||
events: {},
|
events: {},
|
||||||
owner: action.payload.owner,
|
owner: action.payload.owner,
|
||||||
ownerEmails: action.payload.ownerEmails,
|
|
||||||
} as Calendar;
|
} as Calendar;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CalendarEvent } from "../Events/EventsTypes";
|
import { CalendarEvent } from "../Events/EventsTypes";
|
||||||
|
import { OpenPaasUserData } from "../User/type/OpenPaasUserData";
|
||||||
|
|
||||||
export interface Calendar {
|
export interface Calendar {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -7,13 +8,21 @@ export interface Calendar {
|
|||||||
delegated?: boolean;
|
delegated?: boolean;
|
||||||
prodid?: string;
|
prodid?: string;
|
||||||
color?: Record<string, string>;
|
color?: Record<string, string>;
|
||||||
ownerEmails?: string[];
|
owner: OpenPaasUserData;
|
||||||
owner: string;
|
|
||||||
description?: string;
|
description?: string;
|
||||||
calscale?: string;
|
calscale?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
events: Record<string, CalendarEvent>;
|
events: Record<string, CalendarEvent>;
|
||||||
visibility: "private" | "public";
|
visibility: "private" | "public";
|
||||||
|
access?: DelegationAccess;
|
||||||
lastCacheCleared?: number;
|
lastCacheCleared?: number;
|
||||||
syncToken?: string;
|
syncToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DelegationAccess {
|
||||||
|
freebusy: boolean;
|
||||||
|
read: boolean;
|
||||||
|
write: boolean;
|
||||||
|
"write-properties": boolean;
|
||||||
|
all: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData";
|
||||||
import { getUserDetails } from "@/features/User/userAPI";
|
import { getUserDetails } from "@/features/User/userAPI";
|
||||||
import { toRejectedError } from "@/utils/errorUtils";
|
import { toRejectedError } from "@/utils/errorUtils";
|
||||||
import { createAsyncThunk } from "@reduxjs/toolkit";
|
import { createAsyncThunk } from "@reduxjs/toolkit";
|
||||||
@@ -12,8 +13,7 @@ export const addSharedCalendarAsync = createAsyncThunk<
|
|||||||
link: string;
|
link: string;
|
||||||
name: string;
|
name: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
owner: string;
|
owner: OpenPaasUserData;
|
||||||
ownerEmails: string[];
|
|
||||||
},
|
},
|
||||||
{ userId: string; calId: string; cal: CalendarInput },
|
{ userId: string; calId: string; cal: CalendarInput },
|
||||||
{ rejectValue: RejectedError }
|
{ rejectValue: RejectedError }
|
||||||
@@ -37,10 +37,7 @@ export const addSharedCalendarAsync = createAsyncThunk<
|
|||||||
link: `/calendars/${userId}/${calId}.json`,
|
link: `/calendars/${userId}/${calId}.json`,
|
||||||
desc: cal.cal["caldav:description"] ?? "",
|
desc: cal.cal["caldav:description"] ?? "",
|
||||||
name: cal.cal["dav:name"] ?? "",
|
name: cal.cal["dav:name"] ?? "",
|
||||||
owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${
|
owner: ownerData,
|
||||||
ownerData.lastname ?? ""
|
|
||||||
}`,
|
|
||||||
ownerEmails: ownerData.emails,
|
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return rejectWithValue(toRejectedError(err));
|
return rejectWithValue(toRejectedError(err));
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData";
|
||||||
import { userData } from "@/features/User/userDataTypes";
|
import { userData } from "@/features/User/userDataTypes";
|
||||||
import { toRejectedError } from "@/utils/errorUtils";
|
import { toRejectedError } from "@/utils/errorUtils";
|
||||||
import { createAsyncThunk } from "@reduxjs/toolkit";
|
import { createAsyncThunk } from "@reduxjs/toolkit";
|
||||||
@@ -11,8 +12,7 @@ export const createCalendarAsync = createAsyncThunk<
|
|||||||
color: Record<string, string>;
|
color: Record<string, string>;
|
||||||
name: string;
|
name: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
owner: string;
|
owner: OpenPaasUserData;
|
||||||
ownerEmails: string[];
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userData: userData;
|
userData: userData;
|
||||||
@@ -31,9 +31,6 @@ export const createCalendarAsync = createAsyncThunk<
|
|||||||
}
|
}
|
||||||
|
|
||||||
await postCalendar(userData.openpaasId, calId, color, name, desc);
|
await postCalendar(userData.openpaasId, calId, color, name, desc);
|
||||||
const owner = [userData.given_name, userData.family_name]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: userData.openpaasId,
|
userId: userData.openpaasId,
|
||||||
@@ -41,8 +38,13 @@ export const createCalendarAsync = createAsyncThunk<
|
|||||||
color,
|
color,
|
||||||
name,
|
name,
|
||||||
desc,
|
desc,
|
||||||
owner,
|
owner: {
|
||||||
ownerEmails: userData.email ? [userData.email] : [],
|
firstname: userData.given_name,
|
||||||
|
lastname: userData.family_name,
|
||||||
|
id: userData.openpaasId,
|
||||||
|
preferredEmail: userData.email,
|
||||||
|
emails: userData.email ? [userData.email] : [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return rejectWithValue(toRejectedError(err));
|
return rejectWithValue(toRejectedError(err));
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getCalendar } from "../CalendarApi";
|
|||||||
import { RejectedError } from "../types/RejectedError";
|
import { RejectedError } from "../types/RejectedError";
|
||||||
import { extractCalendarEvents } from "../utils/extractCalendarEvents";
|
import { extractCalendarEvents } from "../utils/extractCalendarEvents";
|
||||||
import { defaultColors } from "@/components/Calendar/utils/calendarColorsUtils";
|
import { defaultColors } from "@/components/Calendar/utils/calendarColorsUtils";
|
||||||
|
import { type RootState } from "@/app/store";
|
||||||
|
|
||||||
export const getCalendarDetailAsync = createAsyncThunk<
|
export const getCalendarDetailAsync = createAsyncThunk<
|
||||||
{
|
{
|
||||||
@@ -26,8 +27,16 @@ export const getCalendarDetailAsync = createAsyncThunk<
|
|||||||
{ rejectValue: RejectedError }
|
{ rejectValue: RejectedError }
|
||||||
>(
|
>(
|
||||||
"calendars/getCalendarDetails",
|
"calendars/getCalendarDetails",
|
||||||
async ({ calId, match, calType, signal }, { rejectWithValue }) => {
|
async ({ calId, match, calType, signal }, { rejectWithValue, getState }) => {
|
||||||
try {
|
try {
|
||||||
|
const state = getState() as RootState;
|
||||||
|
const calendarStored =
|
||||||
|
state.calendars[calType === "temp" ? "templist" : "list"][calId];
|
||||||
|
if (!calendarStored) {
|
||||||
|
return rejectWithValue(
|
||||||
|
toRejectedError(new Error(`Calendar ${calId} not found in store`))
|
||||||
|
);
|
||||||
|
}
|
||||||
const calendar = (await getCalendar(
|
const calendar = (await getCalendar(
|
||||||
calId,
|
calId,
|
||||||
match,
|
match,
|
||||||
@@ -42,7 +51,7 @@ export const getCalendarDetailAsync = createAsyncThunk<
|
|||||||
const items = calendar._embedded?.["dav:item"];
|
const items = calendar._embedded?.["dav:item"];
|
||||||
const events: CalendarEvent[] = Array.isArray(items)
|
const events: CalendarEvent[] = Array.isArray(items)
|
||||||
? items.flatMap((item: CalendarItem) =>
|
? items.flatMap((item: CalendarItem) =>
|
||||||
extractCalendarEvents(item, { calId, color })
|
extractCalendarEvents(item, { cal: calendarStored, color })
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const getCalendarsListAsync = createAsyncThunk<
|
|||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
const normalizedCalendars = rawCalendars.map((cal: CalendarData) =>
|
const normalizedCalendars = rawCalendars.map((cal: CalendarData) =>
|
||||||
normalizeCalendar(cal)
|
normalizeCalendar(cal, user.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
const uniqueOwnerIds = Array.from(
|
const uniqueOwnerIds = Array.from(
|
||||||
@@ -59,7 +59,16 @@ export const getCalendarsListAsync = createAsyncThunk<
|
|||||||
}
|
}
|
||||||
|
|
||||||
normalizedCalendars.forEach(
|
normalizedCalendars.forEach(
|
||||||
({ cal, description, delegated, link, id, ownerId, visibility }) => {
|
({
|
||||||
|
cal,
|
||||||
|
description,
|
||||||
|
delegated,
|
||||||
|
link,
|
||||||
|
id,
|
||||||
|
ownerId,
|
||||||
|
visibility,
|
||||||
|
access,
|
||||||
|
}) => {
|
||||||
const ownerData = ownerDataMap.get(ownerId) || {
|
const ownerData = ownerDataMap.get(ownerId) || {
|
||||||
firstname: "",
|
firstname: "",
|
||||||
lastname: "Unknown User",
|
lastname: "Unknown User",
|
||||||
@@ -74,12 +83,12 @@ export const getCalendarsListAsync = createAsyncThunk<
|
|||||||
id,
|
id,
|
||||||
name: cal["dav:name"] ?? "",
|
name: cal["dav:name"] ?? "",
|
||||||
link,
|
link,
|
||||||
owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ownerData.lastname}`,
|
owner: ownerData,
|
||||||
ownerEmails: ownerData.emails,
|
|
||||||
description,
|
description,
|
||||||
delegated,
|
delegated,
|
||||||
color,
|
color,
|
||||||
visibility,
|
visibility,
|
||||||
|
access,
|
||||||
events: {},
|
events: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const putEventAsync = createAsyncThunk<
|
|||||||
try {
|
try {
|
||||||
await putEvent(
|
await putEvent(
|
||||||
newEvent,
|
newEvent,
|
||||||
cal.ownerEmails ? cal.ownerEmails[0] : undefined
|
cal.owner?.emails ? cal.owner.emails[0] : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||||
|
import { buildDelegatedEventURL } from "@/features/Events/eventUtils";
|
||||||
import { toRejectedError } from "@/utils/errorUtils";
|
import { toRejectedError } from "@/utils/errorUtils";
|
||||||
import { createAsyncThunk } from "@reduxjs/toolkit";
|
import { createAsyncThunk } from "@reduxjs/toolkit";
|
||||||
import pMap from "p-map";
|
import pMap from "p-map";
|
||||||
@@ -62,7 +63,11 @@ export const refreshCalendarWithSyncToken = createAsyncThunk<
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
calId: calendar.id,
|
calId: calendar.id,
|
||||||
deletedEvents: toDelete,
|
deletedEvents: toDelete.map((eventURL) =>
|
||||||
|
calendar.delegated
|
||||||
|
? buildDelegatedEventURL(calendar, { URL: eventURL })
|
||||||
|
: eventURL
|
||||||
|
),
|
||||||
createdOrUpdatedEvents: createdOrUpdatedEvents
|
createdOrUpdatedEvents: createdOrUpdatedEvents
|
||||||
.flat()
|
.flat()
|
||||||
.filter(Boolean) as CalendarEvent[],
|
.filter(Boolean) as CalendarEvent[],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const updateEventInstanceAsync = createAsyncThunk<
|
|||||||
"calendars/updateEventInstance",
|
"calendars/updateEventInstance",
|
||||||
async ({ cal, event }, { rejectWithValue }) => {
|
async ({ cal, event }, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
await putEventWithOverrides(event, cal.ownerEmails?.[0]);
|
await putEventWithOverrides(event, cal.owner?.emails?.[0]);
|
||||||
return { calId: cal.id, event };
|
return { calId: cal.id, event };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return rejectWithValue(toRejectedError(err));
|
return rejectWithValue(toRejectedError(err));
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const updateSeriesAsync = createAsyncThunk<
|
|||||||
"calendars/updateSeries",
|
"calendars/updateSeries",
|
||||||
async ({ cal, event, removeOverrides = true }, { rejectWithValue }) => {
|
async ({ cal, event, removeOverrides = true }, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
await updateSeries(event, cal.ownerEmails?.[0] ?? "", removeOverrides);
|
await updateSeries(event, cal.owner?.emails?.[0], removeOverrides);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return rejectWithValue(toRejectedError(err));
|
return rejectWithValue(toRejectedError(err));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function expandEventFunction(
|
|||||||
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
|
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
|
||||||
});
|
});
|
||||||
const events: CalendarEvent[] = extractCalendarEvents(item, {
|
const events: CalendarEvent[] = extractCalendarEvents(item, {
|
||||||
calId: calendar.id,
|
cal: calendar,
|
||||||
color: calendar.color,
|
color: calendar.color,
|
||||||
});
|
});
|
||||||
return events;
|
return events;
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { defaultColors } from "@/components/Calendar/utils/calendarColorsUtils";
|
|||||||
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||||
import { parseCalendarEvent } from "@/features/Events/eventUtils";
|
import { parseCalendarEvent } from "@/features/Events/eventUtils";
|
||||||
import { CalDavItem } from "../api/types";
|
import { CalDavItem } from "../api/types";
|
||||||
|
import { Calendar } from "../CalendarTypes";
|
||||||
import { VCalComponent } from "../types/CalendarData";
|
import { VCalComponent } from "../types/CalendarData";
|
||||||
|
|
||||||
export function extractCalendarEvents(
|
export function extractCalendarEvents(
|
||||||
item: CalDavItem,
|
item: CalDavItem,
|
||||||
options: {
|
options: {
|
||||||
calId: string;
|
cal: Calendar;
|
||||||
color?: Record<string, string>;
|
color?: Record<string, string>;
|
||||||
}
|
}
|
||||||
): CalendarEvent[] {
|
): CalendarEvent[] {
|
||||||
@@ -43,7 +44,7 @@ export function extractCalendarEvents(
|
|||||||
return parseCalendarEvent(
|
return parseCalendarEvent(
|
||||||
eventProps,
|
eventProps,
|
||||||
options?.color ?? defaultColors[0],
|
options?.color ?? defaultColors[0],
|
||||||
options.calId,
|
options.cal,
|
||||||
eventURL,
|
eventURL,
|
||||||
valarm
|
valarm
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { getCalendarVisibility } from "@/components/Calendar/utils/calendarUtils";
|
import {
|
||||||
|
getCalendarDelegationAccess,
|
||||||
|
getCalendarVisibility,
|
||||||
|
} from "@/components/Calendar/utils/calendarUtils";
|
||||||
import { CalendarData } from "../types/CalendarData";
|
import { CalendarData } from "../types/CalendarData";
|
||||||
|
|
||||||
export function normalizeCalendar(rawCalendar: CalendarData) {
|
export function normalizeCalendar(rawCalendar: CalendarData, userId: string) {
|
||||||
const description = rawCalendar["caldav:description"];
|
const description = rawCalendar["caldav:description"];
|
||||||
let delegated = false;
|
let delegated = false;
|
||||||
let source = rawCalendar["calendarserver:source"]
|
let source = rawCalendar["calendarserver:source"]
|
||||||
@@ -18,6 +21,8 @@ export function normalizeCalendar(rawCalendar: CalendarData) {
|
|||||||
const id = source.replace("/calendars/", "").replace(".json", "");
|
const id = source.replace("/calendars/", "").replace(".json", "");
|
||||||
const ownerId = id.split("/")[0];
|
const ownerId = id.split("/")[0];
|
||||||
const visibility = getCalendarVisibility(rawCalendar["acl"] ?? []);
|
const visibility = getCalendarVisibility(rawCalendar["acl"] ?? []);
|
||||||
|
const access = getCalendarDelegationAccess(rawCalendar["acl"] ?? [], userId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cal: rawCalendar,
|
cal: rawCalendar,
|
||||||
description,
|
description,
|
||||||
@@ -27,5 +32,6 @@ export function normalizeCalendar(rawCalendar: CalendarData) {
|
|||||||
id,
|
id,
|
||||||
ownerId,
|
ownerId,
|
||||||
visibility,
|
visibility,
|
||||||
|
access,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { PartStat } from "@/features/User/models/attendee";
|
||||||
import { userData } from "@/features/User/userDataTypes";
|
import { userData } from "@/features/User/userDataTypes";
|
||||||
import { Box, Typography } from "@linagora/twake-mui";
|
import { Box, Typography } from "@linagora/twake-mui";
|
||||||
import { Dispatch, SetStateAction, useState } from "react";
|
import { Dispatch, SetStateAction, useState } from "react";
|
||||||
import { useI18n } from "twake-i18n";
|
import { useI18n } from "twake-i18n";
|
||||||
import { ContextualizedEvent } from "../EventsTypes";
|
import { ContextualizedEvent } from "../EventsTypes";
|
||||||
import { RSVPButton } from "./RSVPButton";
|
import { RSVPButton } from "./RSVPButton";
|
||||||
import { PartStat } from "@/features/User/models/attendee";
|
|
||||||
|
|
||||||
interface AttendanceValidationProps {
|
interface AttendanceValidationProps {
|
||||||
contextualizedEvent: ContextualizedEvent;
|
contextualizedEvent: ContextualizedEvent;
|
||||||
@@ -31,7 +31,14 @@ export function AttendanceValidation({
|
|||||||
!(contextualizedEvent.event?.attendee?.length > 0) &&
|
!(contextualizedEvent.event?.attendee?.length > 0) &&
|
||||||
!contextualizedEvent.event?.organizer;
|
!contextualizedEvent.event?.organizer;
|
||||||
|
|
||||||
if (!((currentUserAttendee || hasNoAttendeesOrOrganizer) && isOwn)) {
|
const createByTheUser = currentUserAttendee || hasNoAttendeesOrOrganizer;
|
||||||
|
const editRightInSelfCalendar = createByTheUser && isOwn;
|
||||||
|
const isDelegatedPublicEvent =
|
||||||
|
contextualizedEvent.calendar.delegated &&
|
||||||
|
(!contextualizedEvent.event.class ||
|
||||||
|
contextualizedEvent.event.class === "PUBLIC");
|
||||||
|
|
||||||
|
if (!(editRightInSelfCalendar || isDelegatedPublicEvent)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,8 +40,10 @@ export function RSVPButton({
|
|||||||
}: RSVPButtonProps) {
|
}: RSVPButtonProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { currentUserAttendee } = contextualizedEvent;
|
const { currentUserAttendee, calendar } = contextualizedEvent;
|
||||||
const showLoading = isLoading && loadingValue === rsvpValue;
|
const showLoading = isLoading && loadingValue === rsvpValue;
|
||||||
|
const isReadDelegated =
|
||||||
|
calendar.delegated && calendar.access?.read && !calendar.access?.write;
|
||||||
const previousPartstatRef = useRef<PartStat | undefined>(
|
const previousPartstatRef = useRef<PartStat | undefined>(
|
||||||
currentUserAttendee?.partstat
|
currentUserAttendee?.partstat
|
||||||
);
|
);
|
||||||
@@ -67,7 +69,6 @@ export function RSVPButton({
|
|||||||
if (previousPartstatRef.current === rsvpValue) {
|
if (previousPartstatRef.current === rsvpValue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For recurring events, don't set loading yet - wait for modal choice
|
// For recurring events, don't set loading yet - wait for modal choice
|
||||||
if (!contextualizedEvent.isRecurring) {
|
if (!contextualizedEvent.isRecurring) {
|
||||||
onLoadingChange(true, rsvpValue);
|
onLoadingChange(true, rsvpValue);
|
||||||
@@ -120,7 +121,7 @@ export function RSVPButton({
|
|||||||
: {},
|
: {},
|
||||||
}}
|
}}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={isLoading}
|
disabled={isLoading || isReadDelegated}
|
||||||
>
|
>
|
||||||
<Box display="flex" alignItems="center" gap={1}>
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
{showLoading && <CircularProgress size={20} color="inherit" />}
|
{showLoading && <CircularProgress size={20} color="inherit" />}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { convertEventDateTimeToISO, resolveTimezoneId } from "@/utils/timezone";
|
|||||||
import { TIMEZONES } from "@/utils/timezone-data";
|
import { TIMEZONES } from "@/utils/timezone-data";
|
||||||
import ICAL from "ical.js";
|
import ICAL from "ical.js";
|
||||||
import { CalDavItem } from "../Calendars/api/types";
|
import { CalDavItem } from "../Calendars/api/types";
|
||||||
|
import { Calendar } from "../Calendars/CalendarTypes";
|
||||||
import {
|
import {
|
||||||
VCalComponent,
|
VCalComponent,
|
||||||
VObjectProperty,
|
VObjectProperty,
|
||||||
@@ -106,7 +107,7 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
|
|||||||
const eventjson = parseCalendarEvent(
|
const eventjson = parseCalendarEvent(
|
||||||
targetVevent[1],
|
targetVevent[1],
|
||||||
event.color ?? {},
|
event.color ?? {},
|
||||||
event.calId,
|
{ id: event?.calId } as Calendar,
|
||||||
event.URL
|
event.URL
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -219,7 +220,12 @@ export const deleteEventInstance = async (event: CalendarEvent) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const exdateValue = event.recurrenceId || event.start;
|
const exdateValue = event.recurrenceId || event.start;
|
||||||
const seriesEvent = parseCalendarEvent(vevents[masterIndex][1], {}, "", "");
|
const seriesEvent = parseCalendarEvent(
|
||||||
|
vevents[masterIndex][1],
|
||||||
|
{},
|
||||||
|
{ id: event.calId } as Calendar,
|
||||||
|
""
|
||||||
|
);
|
||||||
const masterProps = vevents[masterIndex][1];
|
const masterProps = vevents[masterIndex][1];
|
||||||
|
|
||||||
// Check if this date is already in EXDATE (avoid duplicates)
|
// Check if this date is already in EXDATE (avoid duplicates)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
/* eslint-disable react-hooks/rules-of-hooks */
|
/* eslint-disable react-hooks/rules-of-hooks */
|
||||||
import { useAppDispatch, useAppSelector } from "@/app/hooks";
|
import { useAppDispatch, useAppSelector } from "@/app/hooks";
|
||||||
import { CalendarName } from "@/components/Calendar/CalendarName";
|
import { CalendarName } from "@/components/Calendar/CalendarName";
|
||||||
import { getTimezoneOffset } from "@/utils/timezone";
|
|
||||||
import { formatEventChipTitle } from "@/components/Calendar/utils/calendarUtils";
|
import { formatEventChipTitle } from "@/components/Calendar/utils/calendarUtils";
|
||||||
import ResponsiveDialog from "@/components/Dialog/ResponsiveDialog";
|
import ResponsiveDialog from "@/components/Dialog/ResponsiveDialog";
|
||||||
import { EditModeDialog } from "@/components/Event/EditModeDialog";
|
import { EditModeDialog } from "@/components/Event/EditModeDialog";
|
||||||
@@ -9,7 +8,9 @@ import EventDuplication from "@/components/Event/EventDuplicate";
|
|||||||
import { handleDelete } from "@/components/Event/eventHandlers/eventHandlers";
|
import { handleDelete } from "@/components/Event/eventHandlers/eventHandlers";
|
||||||
import { InfoRow } from "@/components/Event/InfoRow";
|
import { InfoRow } from "@/components/Event/InfoRow";
|
||||||
import { renderAttendeeBadge } from "@/components/Event/utils/eventUtils";
|
import { renderAttendeeBadge } from "@/components/Event/utils/eventUtils";
|
||||||
import { browserDefaultTimeZone } from "@/utils/timezone";
|
import { getEffectiveEmail } from "@/utils/getEffectiveEmail";
|
||||||
|
import { isEventOrganiser } from "@/utils/isEventOrganiser";
|
||||||
|
import { browserDefaultTimeZone, getTimezoneOffset } from "@/utils/timezone";
|
||||||
import { DateSelectArg } from "@fullcalendar/core";
|
import { DateSelectArg } from "@fullcalendar/core";
|
||||||
import {
|
import {
|
||||||
AvatarGroup,
|
AvatarGroup,
|
||||||
@@ -23,7 +24,6 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
useTheme,
|
useTheme,
|
||||||
} from "@linagora/twake-mui";
|
} from "@linagora/twake-mui";
|
||||||
import { alpha } from "@mui/material/styles";
|
|
||||||
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
|
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
|
||||||
import CircleIcon from "@mui/icons-material/Circle";
|
import CircleIcon from "@mui/icons-material/Circle";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
@@ -38,10 +38,12 @@ import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
|
|||||||
import RepeatIcon from "@mui/icons-material/Repeat";
|
import RepeatIcon from "@mui/icons-material/Repeat";
|
||||||
import SubjectIcon from "@mui/icons-material/Subject";
|
import SubjectIcon from "@mui/icons-material/Subject";
|
||||||
import VideocamOutlinedIcon from "@mui/icons-material/VideocamOutlined";
|
import VideocamOutlinedIcon from "@mui/icons-material/VideocamOutlined";
|
||||||
|
import { alpha } from "@mui/material/styles";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useI18n } from "twake-i18n";
|
import { useI18n } from "twake-i18n";
|
||||||
import { deleteEventAsync } from "../Calendars/services";
|
import { deleteEventAsync } from "../Calendars/services";
|
||||||
import { userAttendee } from "../User/models/attendee";
|
import { userAttendee } from "../User/models/attendee";
|
||||||
|
import { ToUserData } from "../User/type/OpenPaasUserData";
|
||||||
import { AttendanceValidation } from "./AttendanceValidation/AttendanceValidation";
|
import { AttendanceValidation } from "./AttendanceValidation/AttendanceValidation";
|
||||||
import { createEventContext } from "./createEventContext";
|
import { createEventContext } from "./createEventContext";
|
||||||
import { dlEvent } from "./EventApi";
|
import { dlEvent } from "./EventApi";
|
||||||
@@ -72,7 +74,7 @@ export default function EventPreviewModal({
|
|||||||
const calendar = tempEvent
|
const calendar = tempEvent
|
||||||
? calendars.templist[calId]
|
? calendars.templist[calId]
|
||||||
: calendars.list[calId];
|
: calendars.list[calId];
|
||||||
const event = calendar.events[eventId];
|
const event = calendar?.events[eventId];
|
||||||
const user = useAppSelector((state) => state.user.userData);
|
const user = useAppSelector((state) => state.user.userData);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const infoIconColor = alpha(theme.palette.grey[900], 0.9);
|
const infoIconColor = alpha(theme.palette.grey[900], 0.9);
|
||||||
@@ -80,10 +82,19 @@ export default function EventPreviewModal({
|
|||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const isRecurring = event?.uid?.includes("/");
|
const isRecurring = event?.uid?.includes("/");
|
||||||
const isOwn = calendar.ownerEmails?.includes(user.email);
|
const isOwn = calendar.owner?.emails?.includes(user.email) ?? false;
|
||||||
|
const isDelegated = calendar.delegated;
|
||||||
|
const isWriteDelegated = (isDelegated && calendar.access?.write) ?? false;
|
||||||
|
const effectiveEmail = getEffectiveEmail(
|
||||||
|
calendar,
|
||||||
|
isWriteDelegated,
|
||||||
|
user.email
|
||||||
|
);
|
||||||
const isOrganizer = event.organizer
|
const isOrganizer = event.organizer
|
||||||
? user.email === event.organizer.cal_address
|
? isEventOrganiser(event, effectiveEmail)
|
||||||
: isOwn;
|
: isOwn;
|
||||||
|
const isNotPrivate =
|
||||||
|
event.class !== "PRIVATE" && event.class !== "CONFIDENTIAL";
|
||||||
const [showAllAttendees, setShowAllAttendees] = useState(false);
|
const [showAllAttendees, setShowAllAttendees] = useState(false);
|
||||||
const [openUpdateModal, setOpenUpdateModal] = useState(false);
|
const [openUpdateModal, setOpenUpdateModal] = useState(false);
|
||||||
const [openDuplicateModal, setOpenDuplicateModal] = useState(false);
|
const [openDuplicateModal, setOpenDuplicateModal] = useState(false);
|
||||||
@@ -325,7 +336,7 @@ export default function EventPreviewModal({
|
|||||||
<FileDownloadOutlinedIcon />
|
<FileDownloadOutlinedIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
{isOrganizer && isOwn && (
|
{isOrganizer && (isOwn || (isWriteDelegated && isNotPrivate)) && (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -344,7 +355,7 @@ export default function EventPreviewModal({
|
|||||||
<EditIcon />
|
<EditIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
{((event.class !== "PRIVATE" && !isOwn) || isOwn) && (
|
{((isNotPrivate && !isOwn) || isOwn) && (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(e) => setToggleActionMenu(e.currentTarget)}
|
onClick={(e) => setToggleActionMenu(e.currentTarget)}
|
||||||
@@ -380,7 +391,7 @@ export default function EventPreviewModal({
|
|||||||
setOpenDuplicateModal(true);
|
setOpenDuplicateModal(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{isOwn && (
|
{(isOwn || isWriteDelegated) && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (isRecurring) {
|
if (isRecurring) {
|
||||||
@@ -434,7 +445,11 @@ export default function EventPreviewModal({
|
|||||||
actions={
|
actions={
|
||||||
<AttendanceValidation
|
<AttendanceValidation
|
||||||
contextualizedEvent={contextualizedEvent}
|
contextualizedEvent={contextualizedEvent}
|
||||||
user={user}
|
user={
|
||||||
|
isWriteDelegated && calendar.owner
|
||||||
|
? ToUserData(calendar.owner)
|
||||||
|
: user
|
||||||
|
}
|
||||||
setAfterChoiceFunc={setAfterChoiceFunc}
|
setAfterChoiceFunc={setAfterChoiceFunc}
|
||||||
setOpenEditModePopup={setOpenEditModePopup}
|
setOpenEditModePopup={setOpenEditModePopup}
|
||||||
/>
|
/>
|
||||||
@@ -486,7 +501,7 @@ export default function EventPreviewModal({
|
|||||||
` – ${formatEnd(event.start, event.end, t, timezone, event.allday)} ${!event.allday ? getTimezoneOffset(timezone, new Date(event.start)) : ""}`}
|
` – ${formatEnd(event.start, event.end, t, timezone, event.allday)} ${!event.allday ? getTimezoneOffset(timezone, new Date(event.start)) : ""}`}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{((event.class !== "PRIVATE" && !isOwn) || isOwn) && (
|
{((isNotPrivate && !isOwn) || isOwn) && (
|
||||||
<>
|
<>
|
||||||
{/* Video */}
|
{/* Video */}
|
||||||
{event.x_openpass_videoconference && (
|
{event.x_openpass_videoconference && (
|
||||||
@@ -664,7 +679,7 @@ export default function EventPreviewModal({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{event.class === "PRIVATE" && !isOwn && (
|
{!isNotPrivate && !isOwn && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: "#F3F4F6",
|
backgroundColor: "#F3F4F6",
|
||||||
|
|||||||
@@ -715,7 +715,7 @@ function EventUpdateModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// STEP 3: Persist new event to server
|
// STEP 3: Persist new event to server
|
||||||
await putEvent(finalNewEvent, targetCalendar.ownerEmails?.[0]);
|
await putEvent(finalNewEvent, targetCalendar.owner?.emails?.[0]);
|
||||||
|
|
||||||
// STEP 4: Update Redux store - Add new event first to prevent empty grid
|
// STEP 4: Update Redux store - Add new event first to prevent empty grid
|
||||||
dispatch(updateEventLocal({ calId, event: finalNewEvent }));
|
dispatch(updateEventLocal({ calId, event: finalNewEvent }));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface CalendarEvent {
|
|||||||
transp?: string;
|
transp?: string;
|
||||||
start: string; // ISO date string
|
start: string; // ISO date string
|
||||||
end?: string;
|
end?: string;
|
||||||
class?: string;
|
class?: "PRIVATE" | "PUBLIC" | "CONFIDENTIAL";
|
||||||
x_openpass_videoconference?: string;
|
x_openpass_videoconference?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|||||||
@@ -7,15 +7,17 @@ export function createEventContext(
|
|||||||
calendar: Calendar,
|
calendar: Calendar,
|
||||||
user: userData
|
user: userData
|
||||||
): ContextualizedEvent {
|
): ContextualizedEvent {
|
||||||
const isOwn = calendar.ownerEmails?.includes(user.email) ?? false;
|
const isOwn = calendar.owner?.emails?.includes(user.email) ?? false;
|
||||||
const isRecurring = event?.uid?.includes("/") ?? false;
|
const isRecurring = event?.uid?.includes("/") ?? false;
|
||||||
const isOrganizer = event.organizer
|
const isOrganizer = event.organizer
|
||||||
? user?.email === event.organizer.cal_address
|
? user?.email === event.organizer.cal_address
|
||||||
: isOwn;
|
: isOwn;
|
||||||
|
const attendeeEmail = calendar.delegated
|
||||||
|
? calendar.owner?.emails?.[0]
|
||||||
|
: user.email;
|
||||||
const currentUserAttendee = event.attendee?.find(
|
const currentUserAttendee = event.attendee?.find(
|
||||||
(person) => person.cal_address === user.email
|
(person) => person.cal_address === attendeeEmail
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
event,
|
event,
|
||||||
calendar,
|
calendar,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { convertEventDateTimeToISO, resolveTimezoneId } from "@/utils/timezone";
|
|||||||
import { TIMEZONES } from "@/utils/timezone-data";
|
import { TIMEZONES } from "@/utils/timezone-data";
|
||||||
import ICAL from "ical.js";
|
import ICAL from "ical.js";
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
|
import { Calendar } from "../Calendars/CalendarTypes";
|
||||||
import {
|
import {
|
||||||
RepetitionRule,
|
RepetitionRule,
|
||||||
VObjectProperty,
|
VObjectProperty,
|
||||||
@@ -34,7 +35,7 @@ function inferTimezoneFromValue(
|
|||||||
export function parseCalendarEvent(
|
export function parseCalendarEvent(
|
||||||
data: VObjectProperty[],
|
data: VObjectProperty[],
|
||||||
color: Record<string, string>,
|
color: Record<string, string>,
|
||||||
calendarid: string,
|
calendar: Calendar,
|
||||||
eventURL: string,
|
eventURL: string,
|
||||||
valarm?: VObjectProperty[]
|
valarm?: VObjectProperty[]
|
||||||
): CalendarEvent {
|
): CalendarEvent {
|
||||||
@@ -180,14 +181,19 @@ export function parseCalendarEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
event.calId = calendarid;
|
event.calId = calendar.id;
|
||||||
event.URL = eventURL;
|
event.URL = calendar.delegated
|
||||||
|
? buildDelegatedEventURL(calendar, {
|
||||||
|
...event,
|
||||||
|
URL: eventURL,
|
||||||
|
} as CalendarEvent)
|
||||||
|
: eventURL;
|
||||||
if (!event.uid || !event.start) {
|
if (!event.uid || !event.start) {
|
||||||
console.error(
|
console.error(
|
||||||
`missing crucial event param in calendar ${calendarid} `,
|
`missing crucial event param in calendar ${calendar.id} `,
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
event.error = `missing crucial event param in calendar ${calendarid} `;
|
event.error = `missing crucial event param in calendar ${calendar.id} `;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventTimezone = event.timezone;
|
const eventTimezone = event.timezone;
|
||||||
@@ -556,3 +562,15 @@ export function detectRecurringEventChanges(
|
|||||||
repetitionRulesChanged,
|
repetitionRulesChanged,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildDelegatedEventURL(
|
||||||
|
calendar: Calendar,
|
||||||
|
event: CalendarEvent
|
||||||
|
): string {
|
||||||
|
const calendarBasePath = calendar.link.replace(/\.json$/, "");
|
||||||
|
const eventFilename = event.URL.split("/").pop();
|
||||||
|
if (!eventFilename) {
|
||||||
|
throw new Error(`Cannot extract filename from event URL: ${event.URL}`);
|
||||||
|
}
|
||||||
|
return `${calendarBasePath}/${eventFilename}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ModuleConfiguration } from "../userDataTypes";
|
import { ModuleConfiguration, userData } from "../userDataTypes";
|
||||||
|
|
||||||
export interface OpenPaasUserData {
|
export interface OpenPaasUserData {
|
||||||
firstname?: string;
|
firstname?: string;
|
||||||
@@ -8,4 +8,25 @@ export interface OpenPaasUserData {
|
|||||||
configurations?: {
|
configurations?: {
|
||||||
modules?: ModuleConfiguration[];
|
modules?: ModuleConfiguration[];
|
||||||
};
|
};
|
||||||
|
emails: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToUserData(
|
||||||
|
openpaas: OpenPaasUserData | undefined
|
||||||
|
): userData | undefined {
|
||||||
|
if (!openpaas) return undefined;
|
||||||
|
const email = openpaas.preferredEmail ?? openpaas.emails?.[0] ?? "";
|
||||||
|
|
||||||
|
const given_name = openpaas.firstname ?? "";
|
||||||
|
const family_name = openpaas.lastname ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
email,
|
||||||
|
given_name,
|
||||||
|
family_name,
|
||||||
|
name: [given_name, family_name].filter(Boolean).join(" "),
|
||||||
|
sid: openpaas.id ?? "",
|
||||||
|
sub: openpaas.id ?? "",
|
||||||
|
openpaasId: openpaas.id,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Calendar } from "@/features/Calendars/CalendarTypes";
|
||||||
|
|
||||||
|
export function getEffectiveEmail(
|
||||||
|
calendar: Calendar | undefined,
|
||||||
|
isWriteDelegated: boolean,
|
||||||
|
userAddress: string | undefined
|
||||||
|
): string | undefined {
|
||||||
|
return isWriteDelegated ? calendar?.owner?.emails?.[0] : userAddress;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||||
|
|
||||||
|
export function isEventOrganiser(
|
||||||
|
event: CalendarEvent,
|
||||||
|
effectiveEmail: string | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!event.organizer) return true; // no organizer = assume owner
|
||||||
|
const organizerEmail = event.organizer.cal_address?.toLowerCase();
|
||||||
|
if (!organizerEmail || !effectiveEmail) return false;
|
||||||
|
return organizerEmail === effectiveEmail.toLowerCase();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user