From 72a9d0ee05ad4516517d3fc5aee786cf5d14f5c6 Mon Sep 17 00:00:00 2001
From: Camille Moussu <66134347+Eriikah@users.noreply.github.com>
Date: Mon, 2 Mar 2026 09:34:45 +0100
Subject: [PATCH] [#527] calendar delegation CRUD (#581)
* [#527] added panel to grant right for calendar delegation
* [#527] added rights management to access modal
* [#527] conditionnal rendering of access granting based on rights
* [#527] saves on close except cancel
* [#527] added tests
---
.../Calendars/CalendarAccessRights.test.tsx | 426 ++++++++++++++++++
.../features/Calendars/CalendarModal.test.tsx | 86 ++--
src/components/Calendar/AccessTab.tsx | 41 +-
.../Calendar/CalendarAccessRights.tsx | 354 +++++++++++++++
src/components/Calendar/CalendarModal.tsx | 136 +++++-
src/features/Calendars/CalendarTypes.ts | 10 +
.../Calendars/api/updateDelegationCalendar.ts | 17 +
.../services/getCalendarsListAsync.ts | 17 +-
.../services/updateDelegationCalendarAsync.ts | 33 ++
src/locales/en.json | 6 +
src/utils/accessRightToDavProp.tsx | 16 +
11 files changed, 1073 insertions(+), 69 deletions(-)
create mode 100644 __test__/features/Calendars/CalendarAccessRights.test.tsx
create mode 100644 src/components/Calendar/CalendarAccessRights.tsx
create mode 100644 src/features/Calendars/api/updateDelegationCalendar.ts
create mode 100644 src/features/Calendars/services/updateDelegationCalendarAsync.ts
create mode 100644 src/utils/accessRightToDavProp.tsx
diff --git a/__test__/features/Calendars/CalendarAccessRights.test.tsx b/__test__/features/Calendars/CalendarAccessRights.test.tsx
new file mode 100644
index 0000000..dcee911
--- /dev/null
+++ b/__test__/features/Calendars/CalendarAccessRights.test.tsx
@@ -0,0 +1,426 @@
+import { AccessTab } from "@/components/Calendar/AccessTab";
+import {
+ CalendarAccessRights,
+ UserWithAccess,
+} from "@/components/Calendar/CalendarAccessRights";
+import CalendarPopover from "@/components/Calendar/CalendarModal";
+import { updateDelegationCalendar } from "@/features/Calendars/api/updateDelegationCalendar";
+import { AccessRight, Calendar } from "@/features/Calendars/CalendarTypes";
+import * as eventThunks from "@/features/Calendars/services";
+import * as delegationThunks from "@/features/Calendars/services/updateDelegationCalendarAsync";
+import { getUserDetails } from "@/features/User/userAPI";
+import { accessRightToDavProp } from "@/utils/accessRightToDavProp";
+import { api } from "@/utils/apiUtils";
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { renderWithProviders } from "../../utils/Renderwithproviders";
+
+jest.mock("@/utils/apiUtils", () => ({
+ api: { post: jest.fn() },
+}));
+
+jest.mock("@/features/User/userAPI", () => ({
+ getUserDetails: jest.fn(),
+}));
+
+jest.mock("@/features/Calendars/CalendarApi", () => ({
+ getSecretLink: jest.fn().mockReturnValue(""),
+ exportCalendar: jest.fn(),
+}));
+
+const mockThunkWithUnwrap = (resolvedValue: unknown = {}) =>
+ jest.fn().mockImplementation(() => {
+ const result = Object.assign(Promise.resolve(resolvedValue), {
+ unwrap: () => Promise.resolve(resolvedValue),
+ });
+ return jest.fn().mockReturnValue(result);
+ });
+
+describe("accessRightToDavProp", () => {
+ it("maps 5 (ADMIN) → dav:administration", () => {
+ expect(accessRightToDavProp(5)).toBe("dav:administration");
+ });
+
+ it("maps 3 (EDITOR) → dav:read-write", () => {
+ expect(accessRightToDavProp(3)).toBe("dav:read-write");
+ });
+
+ it("maps 2 (VIEW) → dav:read", () => {
+ expect(accessRightToDavProp(2)).toBe("dav:read");
+ });
+
+ it("defaults to dav:read for unknown values (covered by default case)", () => {
+ expect(accessRightToDavProp(999 as AccessRight)).toBe("dav:read");
+ });
+});
+
+describe("updateDelegationCalendar", () => {
+ beforeEach(() => jest.clearAllMocks());
+
+ it("posts to the correct DAV endpoint with the share body", async () => {
+ (api.post as jest.Mock).mockResolvedValue({ ok: true });
+
+ const share = {
+ set: [{ "dav:href": "mailto:alice@example.com", "dav:read": true }],
+ remove: [],
+ };
+
+ await updateDelegationCalendar("/calendars/user/cal1.json", share);
+
+ await waitFor(() =>
+ expect(api.post).toHaveBeenCalledWith(
+ "dav/calendars/user/cal1.json",
+ expect.objectContaining({
+ body: JSON.stringify({ share }),
+ })
+ )
+ );
+ });
+});
+
+const baseCalendar: Calendar = {
+ id: "user1/cal1",
+ name: "My Calendar",
+ description: "",
+ color: { color: "#0062FF", dark: "#FFF" },
+ link: "/calendars/user1/cal1.json",
+ visibility: "public",
+ events: {},
+ invite: [],
+ owner: { emails: ["user1@example.com"] },
+};
+
+describe("CalendarAccessRights", () => {
+ const mockOnChange = jest.fn();
+ const mockOnInvitesLoaded = jest.fn();
+
+ beforeEach(() => jest.clearAllMocks());
+
+ it("renders the grant access rights section", () => {
+ renderWithProviders(
+
+ );
+
+ expect(
+ screen.getByText("calendarPopover.access.grantAccessRights")
+ ).toBeInTheDocument();
+ });
+
+ it("shows a list of users already passed in via value prop", () => {
+ const users: UserWithAccess[] = [
+ {
+ openpaasId: "abc",
+ displayName: "Alice",
+ email: "alice@example.com",
+ accessRight: 2,
+ },
+ ];
+
+ renderWithProviders(
+
+ );
+
+ expect(screen.getByText("Alice")).toBeInTheDocument();
+ expect(screen.getByText("alice@example.com")).toBeInTheDocument();
+ });
+
+ it("calls onChange with user removed when remove button is clicked", () => {
+ const users: UserWithAccess[] = [
+ {
+ openpaasId: "abc",
+ displayName: "Alice",
+ email: "alice@example.com",
+ accessRight: 2,
+ },
+ ];
+
+ renderWithProviders(
+
+ );
+
+ fireEvent.click(screen.getByLabelText(/remove/i));
+ expect(mockOnChange).toHaveBeenCalledWith([]);
+ });
+
+ it("loads invited users from calendar.invite on mount", async () => {
+ const calendarWithInvite: Calendar = {
+ ...baseCalendar,
+ invite: [
+ {
+ href: "mailto:bob@example.com",
+ principal: "/principals/users/bob123",
+ access: 3,
+ inviteStatus: 1,
+ },
+ ],
+ };
+
+ (getUserDetails as jest.Mock).mockResolvedValue({
+ preferredEmail: "bob@example.com",
+ firstname: "Bob",
+ lastname: "Smith",
+ emails: ["bob@example.com"],
+ });
+
+ renderWithProviders(
+
+ );
+
+ await waitFor(() => expect(getUserDetails).toHaveBeenCalledWith("bob123"));
+ await waitFor(() =>
+ expect(mockOnInvitesLoaded).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({
+ email: "bob@example.com",
+ accessRight: 3,
+ }),
+ ])
+ )
+ );
+ });
+
+ it("skips invite entries where getUserDetails throws", async () => {
+ const calendarWithInvite: Calendar = {
+ ...baseCalendar,
+ invite: [
+ {
+ href: "mailto:ghost@example.com",
+ principal: "/principals/users/ghost",
+ access: 2,
+ inviteStatus: 1,
+ },
+ ],
+ };
+
+ (getUserDetails as jest.Mock).mockRejectedValue(new Error("Not found"));
+
+ renderWithProviders(
+
+ );
+
+ await waitFor(() => expect(mockOnInvitesLoaded).toHaveBeenCalledWith([]));
+ });
+
+ it("shows a loading spinner while invite users are being fetched", async () => {
+ const calendarWithInvite: Calendar = {
+ ...baseCalendar,
+ invite: [
+ {
+ href: "mailto:carol@example.com",
+ principal: "/principals/users/carol",
+ access: 2,
+ inviteStatus: 1,
+ },
+ ],
+ };
+
+ // Never resolves during the assertion window
+ (getUserDetails as jest.Mock).mockImplementation(
+ () => new Promise(() => {})
+ );
+
+ renderWithProviders(
+
+ );
+
+ expect(document.querySelector('[role="progressbar"]')).toBeInTheDocument();
+ });
+});
+
+const userState = {
+ user: {
+ userData: { openpaasId: "user1", email: "user1@example.com" },
+ },
+};
+
+describe("AccessTab – conditional rendering of CalendarAccessRights", () => {
+ const noop = jest.fn();
+
+ afterEach(() => jest.clearAllMocks());
+
+ it("shows CalendarAccessRights when the current user owns the calendar", () => {
+ renderWithProviders(
+ ,
+ {
+ ...userState,
+ calendars: { list: { "user1/cal1": baseCalendar } },
+ }
+ );
+
+ expect(
+ screen.getByText("calendarPopover.access.grantAccessRights")
+ ).toBeInTheDocument();
+ });
+
+ it("hides CalendarAccessRights for a non-owner without admin delegation", () => {
+ const foreignCalendar: Calendar = {
+ ...baseCalendar,
+ id: "otherUser/cal1",
+ invite: [],
+ };
+
+ renderWithProviders(
+ ,
+ {
+ ...userState,
+ calendars: { list: { "otherUser/cal1": foreignCalendar } },
+ }
+ );
+
+ expect(
+ screen.queryByText("calendarPopover.access.grantAccessRights")
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows CalendarAccessRights when the user has access=5 (admin) delegation", () => {
+ const delegatedCalendar: Calendar = {
+ ...baseCalendar,
+ id: "otherUser/cal1",
+ invite: [
+ {
+ href: "mailto:user1@example.com",
+ principal: "/principals/users/user1",
+ access: 5,
+ inviteStatus: 1,
+ },
+ ],
+ };
+
+ renderWithProviders(
+ ,
+ {
+ ...userState,
+ calendars: { list: { "otherUser/cal1": delegatedCalendar } },
+ }
+ );
+
+ expect(
+ screen.getByText("calendarPopover.access.grantAccessRights")
+ ).toBeInTheDocument();
+ });
+});
+
+const existingCalendar: Calendar = {
+ id: "user1/cal1",
+ name: "Existing Cal",
+ description: "Desc",
+ color: { color: "#0062FF", dark: "#FFF" },
+ link: "/calendars/user/cal1",
+ visibility: "public",
+ events: {},
+ invite: [],
+ owner: { emails: ["user1@example.com"] },
+};
+
+describe("CalendarModal – updateDelegationCalendarAsync integration", () => {
+ const mockOnClose = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest
+ .spyOn(eventThunks, "patchCalendarAsync")
+ .mockImplementation(mockThunkWithUnwrap());
+ jest
+ .spyOn(eventThunks, "patchACLCalendarAsync")
+ .mockImplementation(mockThunkWithUnwrap());
+ jest
+ .spyOn(delegationThunks, "updateDelegationCalendarAsync")
+ .mockImplementation(mockThunkWithUnwrap());
+ });
+
+ it("does NOT call updateDelegationCalendarAsync when no users are added or removed", async () => {
+ renderWithProviders(
+ ,
+ { ...userState, calendars: { list: { "user1/cal1": existingCalendar } } }
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /save/i }));
+
+ await waitFor(() => expect(mockOnClose).toHaveBeenCalled());
+
+ expect(
+ delegationThunks.updateDelegationCalendarAsync
+ ).not.toHaveBeenCalled();
+ });
+});
+
+describe("CalendarModal – cancel button", () => {
+ const mockOnClose = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest
+ .spyOn(delegationThunks, "updateDelegationCalendarAsync")
+ .mockImplementation(mockThunkWithUnwrap());
+ jest
+ .spyOn(eventThunks, "patchCalendarAsync")
+ .mockImplementation(mockThunkWithUnwrap());
+ });
+
+ it("calls onClose without saving when Cancel is clicked", async () => {
+ renderWithProviders(
+ ,
+ { ...userState }
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
+
+ await waitFor(() => expect(mockOnClose).toHaveBeenCalled());
+ expect(
+ delegationThunks.updateDelegationCalendarAsync
+ ).not.toHaveBeenCalled();
+ expect(eventThunks.patchCalendarAsync).not.toHaveBeenCalled();
+ });
+});
diff --git a/__test__/features/Calendars/CalendarModal.test.tsx b/__test__/features/Calendars/CalendarModal.test.tsx
index 0e9d6d7..e371b2b 100644
--- a/__test__/features/Calendars/CalendarModal.test.tsx
+++ b/__test__/features/Calendars/CalendarModal.test.tsx
@@ -2,6 +2,7 @@ import CalendarPopover from "@/components/Calendar/CalendarModal";
import { getSecretLink } from "@/features/Calendars/CalendarApi";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import * as eventThunks from "@/features/Calendars/services";
+import * as delegationThunks from "@/features/Calendars/services/updateDelegationCalendarAsync";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../../utils/Renderwithproviders";
@@ -9,11 +10,22 @@ jest.mock("@/features/Calendars/CalendarApi", () => ({
getSecretLink: jest.fn(),
}));
+const mockThunkWithUnwrap = (resolvedValue: unknown = {}) =>
+ jest.fn().mockImplementation(() => {
+ const dispatchResult = Object.assign(Promise.resolve(resolvedValue), {
+ unwrap: () => Promise.resolve(resolvedValue),
+ });
+ return jest.fn().mockReturnValue(dispatchResult);
+ });
+
describe("CalendarPopover", () => {
const mockOnClose = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
+ jest
+ .spyOn(delegationThunks, "updateDelegationCalendarAsync")
+ .mockImplementation(mockThunkWithUnwrap());
});
const renderPopover = (open = true) => {
@@ -54,12 +66,11 @@ describe("CalendarPopover", () => {
expect(descInput).toHaveValue("Test description");
});
- it("dispatches createCalendar and calls onClose when Save clicked", () => {
- const spy = jest
+ it("dispatches createCalendar and calls onClose when Save clicked", async () => {
+ jest
.spyOn(eventThunks, "createCalendarAsync")
- .mockImplementation((payload) => {
- return () => Promise.resolve(payload) as any;
- });
+ .mockImplementation(mockThunkWithUnwrap());
+
renderPopover();
fireEvent.change(screen.getByLabelText(/Name/i), {
@@ -77,9 +88,12 @@ describe("CalendarPopover", () => {
fireEvent.click(screen.getByRole("button", { name: /Create/i }));
- expect(spy).toHaveBeenCalled();
-
- expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
+ await waitFor(() =>
+ expect(eventThunks.createCalendarAsync).toHaveBeenCalled()
+ );
+ await waitFor(() =>
+ expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick")
+ );
});
it("calls onClose when Cancel clicked", () => {
@@ -116,6 +130,9 @@ describe("CalendarPopover (editing mode)", () => {
beforeEach(() => {
jest.clearAllMocks();
+ jest
+ .spyOn(delegationThunks, "updateDelegationCalendarAsync")
+ .mockImplementation(mockThunkWithUnwrap());
});
it("prefills fields when calendar prop is given", () => {
@@ -151,11 +168,9 @@ describe("CalendarPopover (editing mode)", () => {
});
it("allows modifying and saving existing calendar", async () => {
- const spy = jest
+ jest
.spyOn(eventThunks, "patchCalendarAsync")
- .mockImplementation((payload) => {
- return () => Promise.resolve(payload) as any;
- });
+ .mockImplementation(mockThunkWithUnwrap());
renderWithProviders(
{
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
await waitFor(() =>
- expect(spy).toHaveBeenCalledWith(
+ expect(eventThunks.patchCalendarAsync).toHaveBeenCalledWith(
expect.objectContaining({
calId: "user1/cal1",
calLink: "/calendars/user/cal1",
@@ -187,7 +202,7 @@ describe("CalendarPopover (editing mode)", () => {
})
)
);
- expect(mockOnClose).toHaveBeenCalled();
+ await waitFor(() => expect(mockOnClose).toHaveBeenCalled());
});
});
@@ -220,6 +235,9 @@ describe("CalendarPopover - Tabs Scenarios", () => {
beforeEach(() => {
jest.clearAllMocks();
+ jest
+ .spyOn(delegationThunks, "updateDelegationCalendarAsync")
+ .mockImplementation(mockThunkWithUnwrap());
});
it("resets state after closing and reopening", () => {
@@ -265,11 +283,13 @@ describe("CalendarPopover - Tabs Scenarios", () => {
});
it("patches ACL when visibility changes", async () => {
- const patchSpy = jest
+ jest
+ .spyOn(eventThunks, "patchCalendarAsync")
+ .mockImplementation(mockThunkWithUnwrap());
+
+ jest
.spyOn(eventThunks, "patchACLCalendarAsync")
- .mockImplementation((payload) => {
- return () => Promise.resolve(payload) as any;
- });
+ .mockImplementation(mockThunkWithUnwrap());
renderWithProviders(
{
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
await waitFor(() =>
- expect(patchSpy).toHaveBeenCalledWith(
+ expect(eventThunks.patchACLCalendarAsync).toHaveBeenCalledWith(
expect.objectContaining({
calId: "user1/cal1",
request: "",
@@ -352,16 +372,12 @@ describe("CalendarPopover - Tabs Scenarios", () => {
const file = new File(["test"], "events.ics", { type: "text/calendar" });
it("creates a new calendar and imports events when Import with 'new' target", async () => {
- const createSpy = jest
+ jest
.spyOn(eventThunks, "createCalendarAsync")
- .mockImplementation((payload) => {
- return () => Promise.resolve(payload) as any;
- });
- const importSpy = jest
+ .mockImplementation(mockThunkWithUnwrap());
+ jest
.spyOn(eventThunks, "importEventFromFileAsync")
- .mockImplementation((payload) => {
- return () => Promise.resolve(payload) as any;
- });
+ .mockImplementation(mockThunkWithUnwrap());
renderWithProviders(
,
@@ -383,16 +399,18 @@ describe("CalendarPopover - Tabs Scenarios", () => {
// Click Import
fireEvent.click(screen.getByRole("button", { name: "actions.import" }));
- await waitFor(() => expect(createSpy).toHaveBeenCalled());
- await waitFor(() => expect(importSpy).toHaveBeenCalled());
+ await waitFor(() =>
+ expect(eventThunks.createCalendarAsync).toHaveBeenCalled()
+ );
+ await waitFor(() =>
+ expect(eventThunks.importEventFromFileAsync).toHaveBeenCalled()
+ );
});
it("imports into an existing calendar when target is set", async () => {
- const importSpy = jest
+ jest
.spyOn(eventThunks, "importEventFromFileAsync")
- .mockImplementation((payload) => {
- return () => Promise.resolve(payload) as any;
- });
+ .mockImplementation(mockThunkWithUnwrap());
const calendars = {
"user1/cal1": existingCalendar,
@@ -414,7 +432,7 @@ describe("CalendarPopover - Tabs Scenarios", () => {
fireEvent.click(screen.getByRole("button", { name: "actions.import" }));
await waitFor(() =>
- expect(importSpy).toHaveBeenCalledWith(
+ expect(eventThunks.importEventFromFileAsync).toHaveBeenCalledWith(
expect.objectContaining({
calLink: "/calendars/user1/cal1.json",
file,
diff --git a/src/components/Calendar/AccessTab.tsx b/src/components/Calendar/AccessTab.tsx
index 2f4d82f..7f35cb1 100644
--- a/src/components/Calendar/AccessTab.tsx
+++ b/src/components/Calendar/AccessTab.tsx
@@ -1,3 +1,4 @@
+import { useAppSelector } from "@/app/hooks";
import {
exportCalendar,
getSecretLink,
@@ -19,9 +20,28 @@ import { useI18n } from "twake-i18n";
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
import { FieldWithLabel } from "../Event/components/FieldWithLabel";
import { SnackbarAlert } from "../Loading/SnackBarAlert";
+import { CalendarAccessRights, UserWithAccess } from "./CalendarAccessRights";
-export function AccessTab({ calendar }: { calendar: Calendar }) {
+interface AccessTabProps {
+ calendar: Calendar;
+ usersWithAccess: UserWithAccess[];
+ onUsersWithAccessChange: (users: UserWithAccess[]) => void;
+ onInvitesLoaded: (users: UserWithAccess[]) => void;
+}
+
+export function AccessTab({
+ calendar,
+ usersWithAccess,
+ onUsersWithAccessChange,
+ onInvitesLoaded,
+}: AccessTabProps) {
const { t } = useI18n();
+ const userData = useAppSelector((state) => state.user.userData);
+ const isPersonalCalendar = userData.openpaasId === calendar.id.split("/")[0];
+ const isDelegatedWithAdministration = calendar.invite?.find(
+ (invite) => invite.href.includes(userData.email) && invite.access === 5
+ );
+
const calDAVLink = `${window.DAV_BASE_URL}${calendar.link.replace(".json", "")}`;
const [secretLink, setSecretLink] = useState("");
@@ -60,11 +80,8 @@ export function AccessTab({ calendar }: { calendar: Calendar }) {
const exportedData = await exportCalendar(
calendar.link.replace(".json", "")
);
- const blob = new Blob([exportedData], {
- type: "text/calendar",
- });
+ const blob = new Blob([exportedData], { type: "text/calendar" });
const url = URL.createObjectURL(blob);
-
const link = document.createElement("a");
link.href = url;
link.download = `${calendar.id.split("/")[1]}.ics`;
@@ -74,7 +91,6 @@ export function AccessTab({ calendar }: { calendar: Calendar }) {
URL.revokeObjectURL(url);
} catch (e) {
setExportError((e as Error).message);
- setExportLoading(false);
} finally {
setExportLoading(false);
}
@@ -82,6 +98,15 @@ export function AccessTab({ calendar }: { calendar: Calendar }) {
return (
<>
+ {(isPersonalCalendar || isDelegatedWithAdministration) && (
+
+ )}
+
{!!window.DAV_BASE_URL && (
@@ -108,6 +133,7 @@ export function AccessTab({ calendar }: { calendar: Calendar }) {
)}
+
{t("calendar.exportDesc")}
-