From 40c383c334629e6d7431a7bc92c6f2c7bf809f57 Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Tue, 7 Oct 2025 11:14:08 +0200 Subject: [PATCH] Revamp calendar dialog (#158) Co-authored-by: Camille Moussu --- .../components/CalendarSelection.test.tsx | 2 +- .../features/Calendars/CalendarModal.test.tsx | 274 ++++++++++++-- .../features/Calendars/CalendarSlice.test.tsx | 342 ++++++++++++++++++ __test__/features/Events/EventApi.test.tsx | 32 +- __test__/utils/calendarUtils.test.tsx | 74 ++++ src/components/Calendar/AccessTab.tsx | 37 ++ src/components/Calendar/CalendarModal.tsx | 308 +++++++++++----- src/components/Calendar/ImportTab.tsx | 123 +++++++ src/components/Calendar/SettingsTab.tsx | 113 ++++++ .../Calendar/utils/calendarUtils.ts | 23 ++ src/components/Loading/SnackBarAlert.tsx | 25 ++ src/features/Calendars/CalendarApi.ts | 11 + src/features/Calendars/CalendarSlice.ts | 58 ++- src/features/Calendars/CalendarTypes.ts | 1 + src/features/Events/EventApi.ts | 7 + src/utils/apiUtils.ts | 8 + 16 files changed, 1306 insertions(+), 132 deletions(-) create mode 100644 __test__/features/Calendars/CalendarSlice.test.tsx create mode 100644 __test__/utils/calendarUtils.test.tsx create mode 100644 src/components/Calendar/AccessTab.tsx create mode 100644 src/components/Calendar/ImportTab.tsx create mode 100644 src/components/Calendar/SettingsTab.tsx create mode 100644 src/components/Loading/SnackBarAlert.tsx diff --git a/__test__/components/CalendarSelection.test.tsx b/__test__/components/CalendarSelection.test.tsx index 70754c9..c619964 100644 --- a/__test__/components/CalendarSelection.test.tsx +++ b/__test__/components/CalendarSelection.test.tsx @@ -122,7 +122,7 @@ describe("CalendarSelection", () => { fireEvent.click(addButtons[1]); await waitFor(() => - expect(screen.getByText("Calendar configuration")).toBeInTheDocument() + expect(screen.getByText(/Add new calendar/i)).toBeInTheDocument() ); }); diff --git a/__test__/features/Calendars/CalendarModal.test.tsx b/__test__/features/Calendars/CalendarModal.test.tsx index f78fbef..1783dcd 100644 --- a/__test__/features/Calendars/CalendarModal.test.tsx +++ b/__test__/features/Calendars/CalendarModal.test.tsx @@ -2,6 +2,7 @@ import { screen, fireEvent, waitFor } from "@testing-library/react"; import CalendarPopover from "../../../src/components/Calendar/CalendarModal"; import { renderWithProviders } from "../../utils/Renderwithproviders"; import * as eventThunks from "../../../src/features/Calendars/CalendarSlice"; +import { Calendars } from "../../../src/features/Calendars/CalendarTypes"; describe("CalendarPopover", () => { const mockOnClose = jest.fn(); @@ -32,8 +33,7 @@ describe("CalendarPopover", () => { renderPopover(); expect(screen.getByLabelText(/Name/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/Description/i)).toBeInTheDocument(); - expect(screen.getByText("Calendar configuration")).toBeInTheDocument(); + expect(screen.getByText(/add description/i)).toBeInTheDocument(); }); it("updates name and description fields", () => { @@ -43,26 +43,12 @@ describe("CalendarPopover", () => { fireEvent.change(nameInput, { target: { value: "My Calendar" } }); expect(nameInput).toHaveValue("My Calendar"); + fireEvent.click(screen.getByText(/add description/i)); const descInput = screen.getByLabelText(/Description/i); fireEvent.change(descInput, { target: { value: "Test description" } }); expect(descInput).toHaveValue("Test description"); }); - it("selects a color when a color button is clicked", () => { - renderPopover(); - - // There are multiple color buttons; pick the first - const colorButtons = screen.getAllByRole("button", { - name: /select color/i, - }); - fireEvent.click(colorButtons[0]); - - // The header background should update (check via inline style) - expect( - screen.getByText("Calendar configuration").style.backgroundColor - ).toBe("rgb(52, 211, 153)"); - }); - it("dispatches createCalendar and calls onClose when Save clicked", () => { const spy = jest .spyOn(eventThunks, "createCalendarAsync") @@ -74,6 +60,7 @@ describe("CalendarPopover", () => { fireEvent.change(screen.getByLabelText(/Name/i), { target: { value: "Test Calendar" }, }); + fireEvent.click(screen.getByText(/add description/i)); fireEvent.change(screen.getByLabelText(/Description/i), { target: { value: "Test Description" }, }); @@ -83,7 +70,7 @@ describe("CalendarPopover", () => { }); fireEvent.click(colorButtons[0]); - fireEvent.click(screen.getByText(/Save/i)); + fireEvent.click(screen.getByText(/Create/)); expect(spy).toHaveBeenCalled(); @@ -111,7 +98,7 @@ describe("CalendarPopover (editing mode)", () => { }, }; - const existingCalendar = { + const existingCalendar: Calendars = { id: "user1/cal1", link: "/calendars/user/cal1", name: "Work Calendar", @@ -119,6 +106,7 @@ describe("CalendarPopover (editing mode)", () => { color: "#33B679", owner: "alice", ownerEmails: ["alice@example.com"], + visibility: "public", events: {}, }; @@ -138,9 +126,6 @@ describe("CalendarPopover (editing mode)", () => { expect(screen.getByLabelText(/Name/i)).toHaveValue("Work Calendar"); expect(screen.getByLabelText(/Description/i)).toHaveValue("Team meetings"); - expect(screen.getByText("Calendar configuration")).toHaveStyle({ - backgroundColor: "#33B679", - }); }); test("Save button is disabled when name is empty or whitespace only", () => { @@ -148,7 +133,7 @@ describe("CalendarPopover (editing mode)", () => { user: baseUser, }); - const saveButton = screen.getByRole("button", { name: /save/i }); + const saveButton = screen.getByRole("button", { name: /create/i }); expect(saveButton).toBeDisabled(); // only spaces const nameInput = screen.getByLabelText(/name/i); @@ -201,3 +186,246 @@ describe("CalendarPopover (editing mode)", () => { expect(mockOnClose).toHaveBeenCalled(); }); }); + +describe("CalendarPopover - Tabs Scenarios", () => { + const mockOnClose = jest.fn(); + const baseUser = { + userData: { + openpaasId: "user1", + }, + }; + + const writeText = jest.fn(); + + Object.assign(navigator, { + clipboard: { + writeText, + }, + }); + + const existingCalendar: Calendars = { + id: "user1/cal1", + link: "/calendars/user1/cal1.json", + name: "Work Calendar", + description: "Team meetings", + color: "#33B679", + owner: "alice", + ownerEmails: ["alice@example.com"], + visibility: "public", + events: {}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("resets state after closing and reopening", () => { + const { rerender } = renderWithProviders( + , + { user: baseUser } + ); + + // Enter some data + fireEvent.change(screen.getByLabelText(/Name/i), { + target: { value: "Temp Calendar" }, + }); + fireEvent.click(screen.getByText(/Cancel/i)); + + expect(mockOnClose).toHaveBeenCalled(); + + // Reopen: state should be reset + rerender(); + expect(screen.getByLabelText(/Name/i)).toHaveValue(""); + }); + + it("shows Access tab only when editing an existing calendar", () => { + renderWithProviders( + , + { user: baseUser } + ); + + expect(screen.getByRole("tab", { name: /Access/i })).toBeInTheDocument(); + }); + + it("does not show Access tab when creating new calendar", () => { + renderWithProviders(, { + user: baseUser, + }); + + expect( + screen.queryByRole("tab", { name: /Access/i }) + ).not.toBeInTheDocument(); + }); + + it("patches ACL when visibility changes", async () => { + const patchSpy = jest + .spyOn(eventThunks, "patchACLCalendarAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + renderWithProviders( + , + { user: baseUser } + ); + + // By default: "All" (public) is selected + const publicButton = screen.getByRole("button", { name: /All/i }); + const privateButton = screen.getByRole("button", { name: /You/i }); + + expect(publicButton).toHaveAttribute("aria-pressed", "true"); + expect(privateButton).toHaveAttribute("aria-pressed", "false"); + + // Change to private + fireEvent.click(privateButton); + + expect(privateButton).toHaveAttribute("aria-pressed", "true"); + expect(publicButton).toHaveAttribute("aria-pressed", "false"); + + // Save + fireEvent.click(screen.getByText(/Save/i)); + + await waitFor(() => + expect(patchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + calId: "user1/cal1", + request: "", + }) + ) + ); + }); + + it("copies CalDAV link from Access tab", async () => { + (window as any).CALENDAR_BASE_URL = "https://cal.example.org"; + Object.assign(navigator, { + clipboard: { writeText: jest.fn() }, + }); + + renderWithProviders( + , + { user: baseUser } + ); + + // Switch to Access tab + fireEvent.click(screen.getByRole("tab", { name: /Access/i })); + + // Expect text field with caldav link + const input = screen.getByLabelText(/CalDAV access/i); + expect(input).toHaveValue("https://cal.example.org/calendars/user1/cal1"); + + // Click copy button + const copyButton = screen.getAllByRole("button")[0]; + fireEvent.click(copyButton); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + "https://cal.example.org/calendars/user1/cal1" + ); + + // Snackbar should appear + await waitFor(() => + expect(screen.getByText(/Link copied!/i)).toBeInTheDocument() + ); + }); + + describe("Import flow", () => { + 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 + .spyOn(eventThunks, "createCalendarAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + const importSpy = jest + .spyOn(eventThunks, "importEventFromFileAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + renderWithProviders( + , + { + user: baseUser, + } + ); + + // Switch to Import tab + fireEvent.click(screen.getByRole("tab", { name: /Import/i })); + + // Provide new calendar params + fireEvent.change(screen.getByLabelText(/Name/i), { + target: { value: "Imported Calendar" }, + }); + const fileInput = screen.getByLabelText(/select file/i); + fireEvent.change(fileInput, { target: { files: [file] } }); + + // Click Import + fireEvent.click(screen.getByRole("button", { name: "Import" })); + + await waitFor(() => expect(createSpy).toHaveBeenCalled()); + await waitFor(() => expect(importSpy).toHaveBeenCalled()); + }); + + it("imports into an existing calendar when target is set", async () => { + const importSpy = jest + .spyOn(eventThunks, "importEventFromFileAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + const calendars = { + "user1/cal1": existingCalendar, + }; + + renderWithProviders( + , + { user: baseUser, calendars: { list: calendars } } + ); + + fireEvent.click(screen.getByRole("tab", { name: /Import/i })); + const fileInput = screen.getByLabelText(/select file/i); + fireEvent.change(fileInput, { target: { files: [file] } }); + + fireEvent.click(screen.getByRole("button", { name: "Import" })); + + await waitFor(() => + expect(importSpy).toHaveBeenCalledWith( + expect.objectContaining({ + calLink: "/calendars/user1/cal1.json", + file, + }) + ) + ); + }); + + it("disables Import button until a file is uploaded", () => { + renderWithProviders( + , + { + user: baseUser, + } + ); + + fireEvent.click(screen.getByRole("tab", { name: /Import/i })); + + const importButton = screen.getByRole("button", { name: /Import/i }); + expect(importButton).toBeDisabled(); + }); + }); +}); diff --git a/__test__/features/Calendars/CalendarSlice.test.tsx b/__test__/features/Calendars/CalendarSlice.test.tsx new file mode 100644 index 0000000..42bfc53 --- /dev/null +++ b/__test__/features/Calendars/CalendarSlice.test.tsx @@ -0,0 +1,342 @@ +import reducer, { + addEvent, + removeEvent, + createCalendar, + updateEventLocal, + removeTempCal, + getCalendarsListAsync, + getTempCalendarsListAsync, + getCalendarDetailAsync, + putEventAsync, + getEventAsync, + patchCalendarAsync, + removeCalendarAsync, + moveEventAsync, + patchACLCalendarAsync, + createCalendarAsync, + addSharedCalendarAsync, + deleteEventAsync, +} from "../../../src/features/Calendars/CalendarSlice"; + +import * as calAPI from "../../../src/features/Calendars/CalendarApi"; +import * as userAPI from "../../../src/features/User/userAPI"; + +import { configureStore } from "@reduxjs/toolkit"; +import { Calendars } from "../../../src/features/Calendars/CalendarTypes"; +import { CalendarEvent } from "../../../src/features/Events/EventsTypes"; + +jest.mock("../../../src/features/Calendars/CalendarApi"); +jest.mock("../../../src/features/User/userAPI"); +jest.mock("../../../src/features/Events/EventApi"); +jest.mock("../../../src/features/Events/eventUtils"); +jest.mock("../../../src/utils/apiUtils"); + +describe("CalendarSlice", () => { + const initialState = { + list: {}, + templist: {}, + pending: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("reducers", () => { + it("createCalendar adds new calendar", () => { + const action = createCalendar({ + name: "Test Cal", + color: "#ff0000", + description: "desc", + }); + const state = reducer(initialState, action); + const values = Object.values(state.list); + expect(values[0].name).toBe("Test Cal"); + expect(values[0].color).toBe("#ff0000"); + }); + + it("addEvent adds event to calendar", () => { + const calId = "user/cal"; + const event = { uid: "event1", title: "My Event" } as any; + const stateWithCal = { + ...initialState, + list: { [calId]: { id: calId, events: {} } as any }, + }; + const state = reducer( + stateWithCal, + addEvent({ calendarUid: calId, event }) + ); + expect(state.list[calId].events["event1"]).toEqual( + expect.objectContaining({ uid: "event1" }) + ); + }); + + it("removeEvent deletes event", () => { + const calId = "user/cal"; + const stateWithEvent = { + ...initialState, + list: { + [calId]: { + id: calId, + events: { e1: { uid: "e1" } }, + } as unknown as Calendars, + }, + }; + const state = reducer( + stateWithEvent, + removeEvent({ calendarUid: calId, eventUid: "e1" }) + ); + expect(state.list[calId].events).toEqual({}); + }); + + it("updateEventLocal updates an event", () => { + const calId = "user/cal"; + const stateWithEvent = { + ...initialState, + list: { + [calId]: { + id: calId, + events: { e1: { uid: "e1", title: "Old" } }, + } as unknown as Calendars, + }, + }; + const state = reducer( + stateWithEvent, + updateEventLocal({ calId, event: { uid: "e1", title: "New" } as any }) + ); + expect(state.list[calId].events.e1.title).toBe("New"); + }); + + it("removeTempCal deletes temp calendar", () => { + const stateWithTemp = { + ...initialState, + templist: { temp1: { id: "temp1" } as any }, + }; + const state = reducer(stateWithTemp, removeTempCal("temp1")); + expect(state.templist).toEqual({}); + }); + }); + + describe("extraReducers (thunks)", () => { + const storeFactory = () => + configureStore({ + reducer: { calendars: reducer }, + }); + + it("getCalendarsListAsync.fulfilled replaces list", async () => { + (userAPI.getOpenPaasUser as jest.Mock).mockResolvedValue({ id: "u1" }); + (calAPI.getCalendars as jest.Mock).mockResolvedValue({ + _embedded: { "dav:calendar": [] }, + }); + (userAPI.getUserDetails as jest.Mock).mockResolvedValue({ + firstname: "Alice", + lastname: "Smith", + emails: ["a@b.com"], + }); + + const store = storeFactory(); + await store.dispatch(getCalendarsListAsync() as any); + const state = store.getState().calendars; + expect(state.list).toEqual({}); + }); + + it("patchCalendarAsync.fulfilled updates calendar fields", () => { + const calId = "c1"; + const prev = { + ...initialState, + list: { c1: { id: calId, events: { e1: { uid: "e1" } } } as any }, + }; + const patch = { name: "N", desc: "D", color: "#00f" }; + const state = reducer( + prev, + patchCalendarAsync.fulfilled( + { calId, calLink: "link", patch }, + "req1", + { + calId, + calLink: "link", + patch, + } + ) + ); + expect(state.list[calId].name).toBe("N"); + expect(state.list[calId].description).toBe("D"); + expect(state.list[calId].color).toBe("#00f"); + }); + + it("removeCalendarAsync.fulfilled deletes calendar", () => { + const prev = { + ...initialState, + list: { c1: { id: "c1" } as any }, + }; + const state = reducer( + prev, + removeCalendarAsync.fulfilled({ calId: "c1" }, "req2", { + calId: "c1", + calLink: "l", + }) + ); + expect(state.list).toEqual({}); + }); + + it("patchACLCalendarAsync.fulfilled sets visibility", () => { + const prev = { + ...initialState, + list: { c1: { id: "c1", visibility: "public" } as any }, + }; + const state = reducer( + prev, + patchACLCalendarAsync.fulfilled( + { calId: "c1", calLink: "l", request: "" }, + "req3", + { calId: "c1", calLink: "l", request: "" } + ) + ); + expect(state.list.c1.visibility).toBe("private"); + }); + + it("createCalendarAsync.fulfilled adds a new calendar", () => { + const payload = { + userId: "u1", + calId: "cal1", + color: "#f00", + name: "Test", + desc: "Desc", + owner: "Owner", + ownerEmails: ["o@example.com"], + }; + const state = reducer( + initialState, + createCalendarAsync.fulfilled(payload, "req4", payload) + ); + expect(state.list["u1/cal1"].name).toBe("Test"); + expect(state.list["u1/cal1"].color).toBe("#f00"); + }); + + it("addSharedCalendarAsync.fulfilled adds shared calendar", () => { + const payload = { + calId: "c1", + color: "#0f0", + link: "/calendars/u1/c1.json", + name: "Shared", + desc: "Shared Desc", + owner: "O", + ownerEmails: ["o@example.com"], + }; + const mockCal = { + cal: { + _links: { self: { href: "/calendars/u1/c1.json" } }, + "apple:color": "#0f0", + "caldav:description": "Shared Desc", + "dav:name": "Shared", + }, + }; + const state = reducer( + initialState, + addSharedCalendarAsync.fulfilled(payload, "req5", { + userId: "u1", + calId: "c1", + cal: mockCal, + }) + ); + expect(state.list["c1"].name).toBe("Shared"); + }); + + it("deleteEventAsync.fulfilled removes event", () => { + const calId = "c1"; + const prev = { + ...initialState, + list: { + [calId]: { + id: calId, + events: { e1: { uid: "e1" } }, + } as unknown as Calendars, + }, + }; + const state = reducer( + prev, + deleteEventAsync.fulfilled({ calId, eventId: "e1" }, "req6", { + calId, + eventId: "e1", + eventURL: "e1.ics", + }) + ); + expect(state.list[calId].events).toEqual({}); + }); + + it("getTempCalendarsListAsync.fulfilled updates templist", () => { + const payload = { + t1: { + id: "t1", + name: "Temp", + color: "#aaa", + events: {}, + visibility: "public", + owner: "O", + ownerEmails: ["o@o.com"], + link: "/calendars/t1.json", + description: "desc", + } as Calendars, + }; + const state = reducer( + initialState, + getTempCalendarsListAsync.fulfilled(payload, "req7", { + openpaasId: "u1", + color: "#aaa", + displayName: "test", + avatarUrl: "", + email: "test@test.com", + }) + ); + expect(state.templist.t1.name).toBe("Temp"); + }); + + it("putEventAsync.fulfilled updates calendar events", () => { + const cal = { id: "c1", events: {} } as any; + const payload = { calId: "c1", events: [{ uid: "e1" }] as any[] }; + const state = reducer( + initialState, + putEventAsync.fulfilled(payload, "req8", { + cal, + newEvent: { uid: "e1" } as CalendarEvent, + }) + ); + expect(state.list.c1.events.e1.uid).toBe("e1"); + }); + + it("getEventAsync.fulfilled adds single event", () => { + const payload = { calId: "c1", event: { uid: "e1" } as any }; + const state = reducer( + initialState, + getEventAsync.fulfilled(payload, "req9", { uid: "e1" } as any) + ); + expect(state.list.c1.events.e1.uid).toBe("e1"); + }); + + it("moveEventAsync.fulfilled updates events after move", () => { + const cal = { id: "c1", events: {} } as any; + const payload = { calId: "c1", events: [{ uid: "e1" }] as any[] }; + const state = reducer( + initialState, + moveEventAsync.fulfilled(payload, "req10", { + cal, + newEvent: { uid: "e1" } as CalendarEvent, + newURL: "e1.ics", + }) + ); + expect(state.list.c1.events.e1.uid).toBe("e1"); + }); + + it("getCalendarDetailAsync.fulfilled adds calendar events", () => { + const payload = { calId: "c1", events: [{ uid: "e1" }] as any[] }; + const state = reducer( + initialState, + getCalendarDetailAsync.fulfilled(payload, "req11", { + calId: "c1", + match: { start: "", end: "" }, + }) + ); + expect(state.list.c1.events.e1.uid).toBe("e1"); + }); + }); +}); diff --git a/__test__/features/Events/EventApi.test.tsx b/__test__/features/Events/EventApi.test.tsx index f1553d9..f33d371 100644 --- a/__test__/features/Events/EventApi.test.tsx +++ b/__test__/features/Events/EventApi.test.tsx @@ -2,7 +2,9 @@ import { putEvent, moveEvent, deleteEvent, + importEventFromFile, } from "../../../src/features/Events/EventApi"; +import { CalendarEvent } from "../../../src/features/Events/EventsTypes"; import { calendarEventToJCal } from "../../../src/features/Events/eventUtils"; import { clientConfig } from "../../../src/features/User/oidcAuth"; import { api } from "../../../src/utils/apiUtils"; @@ -18,8 +20,8 @@ const mockEvent = { timezone: "UTC", calId: "667037022b752d0026472254/cal1", URL: "/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics", - start: day, - end: day, + start: day.toISOString(), + end: day.toISOString(), status: "PUBLIC", organizer: { cn: "test", cal_address: "test@test.com" }, attendee: [ @@ -40,14 +42,14 @@ const mockEvent = { cutype: "INDIVIDUAL", }, ], -}; +} as CalendarEvent; describe("eventApi", () => { beforeEach(() => { jest.clearAllMocks(); }); - test("putEvent sends PUT request with JCal body", async () => { + it("putEvent sends PUT request with JCal body", async () => { const mockResponse = { status: 201, url: "/dav/cals/test.ics" }; (api as unknown as jest.Mock).mockReturnValue(mockResponse); const result = await putEvent(mockEvent); @@ -63,7 +65,7 @@ describe("eventApi", () => { expect(result).toBe(mockResponse); }); - test("putEvent logs when status is 201", async () => { + it("putEvent logs when status is 201", async () => { const mockResponse = { status: 201, url: "/dav/cals/test.ics" }; (api as unknown as jest.Mock).mockReturnValue(mockResponse); const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); @@ -74,7 +76,7 @@ describe("eventApi", () => { logSpy.mockRestore(); }); - test("moveEvent sends MOVE request with destination header", async () => { + it("moveEvent sends MOVE request with destination header", async () => { const mockResponse = { status: 204 }; (api as unknown as jest.Mock).mockReturnValue({ json: jest.fn().mockResolvedValue(mockResponse), @@ -92,7 +94,7 @@ describe("eventApi", () => { ); }); - test("deleteEvent sends DELETE request and returns json response", async () => { + it("deleteEvent sends DELETE request and returns json response", async () => { const mockResponse = { ok: true }; (api as unknown as jest.Mock).mockReturnValue({ json: jest.fn().mockResolvedValue(mockResponse), @@ -105,6 +107,22 @@ describe("eventApi", () => { }); }); + it("import event file", async () => { + const mockResponse = { status: 202 }; + (api.post as unknown as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse), + }); + + await importEventFromFile("123456789", "/calendar/calLink.json"); + + expect(api.post).toHaveBeenCalledWith("api/import", { + body: JSON.stringify({ + fileId: "123456789", + target: "/calendar/calLink.json", + }), + }); + }); + test("putEvent handles byday field correctly", async () => { const mockResponse = { status: 201, url: "/dav/cals/test.ics" }; (api as unknown as jest.Mock).mockReturnValue(mockResponse); diff --git a/__test__/utils/calendarUtils.test.tsx b/__test__/utils/calendarUtils.test.tsx new file mode 100644 index 0000000..d1aa19c --- /dev/null +++ b/__test__/utils/calendarUtils.test.tsx @@ -0,0 +1,74 @@ +import { getCalendarVisibility } from "../../src/components/Calendar/utils/calendarUtils"; + +interface AclEntry { + privilege: string; + principal: string; + protected: boolean; +} + +describe("getCalendarVisibility", () => { + it("returns 'public' when {DAV:}authenticated has {DAV:}read", () => { + const acl: AclEntry[] = [ + { + privilege: "{DAV:}read", + principal: "{DAV:}authenticated", + protected: true, + }, + ]; + expect(getCalendarVisibility(acl)).toBe("public"); + }); + + it("returns 'private' when {DAV:}authenticated only has read-free-busy", () => { + const acl: AclEntry[] = [ + { + privilege: "{urn:ietf:params:xml:ns:caldav}read-free-busy", + principal: "{DAV:}authenticated", + protected: true, + }, + ]; + expect(getCalendarVisibility(acl)).toBe("private"); + }); + + it("returns 'private' when {DAV:}authenticated is not present", () => { + const acl: AclEntry[] = [ + { + privilege: "{DAV:}read", + principal: "principals/users/123", + protected: true, + }, + ]; + expect(getCalendarVisibility(acl)).toBe("private"); + }); + + it("ignores non-authenticated principals and still returns 'private'", () => { + const acl: AclEntry[] = [ + { + privilege: "{DAV:}read", + principal: "principals/users/other", + protected: true, + }, + { + privilege: "{DAV:}write", + principal: "principals/users/other", + protected: true, + }, + ]; + expect(getCalendarVisibility(acl)).toBe("private"); + }); + + it("stops early when it finds a {DAV:}read for {DAV:}authenticated", () => { + const acl: AclEntry[] = [ + { + privilege: "{DAV:}read", + principal: "{DAV:}authenticated", + protected: true, + }, + { + privilege: "{DAV:}write", + principal: "{DAV:}authenticated", + protected: true, + }, + ]; + expect(getCalendarVisibility(acl)).toBe("public"); + }); +}); diff --git a/src/components/Calendar/AccessTab.tsx b/src/components/Calendar/AccessTab.tsx new file mode 100644 index 0000000..889aa63 --- /dev/null +++ b/src/components/Calendar/AccessTab.tsx @@ -0,0 +1,37 @@ +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import { Box, IconButton, TextField } from "@mui/material"; +import { useState } from "react"; +import { Calendars } from "../../features/Calendars/CalendarTypes"; +import { SnackbarAlert } from "../Loading/SnackBarAlert"; + +export function AccessTab({ calendar }: { calendar: Calendars }) { + const calDAVLink = `${(window as any).CALENDAR_BASE_URL}${calendar.link.replace(".json", "")}`; + const [open, setOpen] = useState(false); + const handleCopy = () => { + navigator.clipboard.writeText(calDAVLink); + setOpen(true); + }; + + return ( + <> + + + + + ), + }, + }} + /> + + + + ); +} diff --git a/src/components/Calendar/CalendarModal.tsx b/src/components/Calendar/CalendarModal.tsx index 5d2c5c8..fc4e528 100644 --- a/src/components/Calendar/CalendarModal.tsx +++ b/src/components/Calendar/CalendarModal.tsx @@ -1,19 +1,17 @@ +import { Button, DialogActions, Tab, Tabs } from "@mui/material"; import { useEffect, useState } from "react"; -import { - createCalendarAsync /*, updateCalendarAsync */, - patchCalendarAsync, -} from "../../features/Calendars/CalendarSlice"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { - TextField, - Button, - Dialog, - DialogTitle, - DialogContent, - DialogActions, -} from "@mui/material"; -import { ColorPicker } from "./CalendarColorPicker"; + createCalendarAsync, + importEventFromFileAsync, + patchACLCalendarAsync, + patchCalendarAsync, +} from "../../features/Calendars/CalendarSlice"; import { Calendars } from "../../features/Calendars/CalendarTypes"; +import { ResponsiveDialog } from "../Dialog"; +import { AccessTab } from "./AccessTab"; +import { ImportTab } from "./ImportTab"; +import { SettingsTab } from "./SettingsTab"; function CalendarPopover({ open, @@ -30,101 +28,215 @@ function CalendarPopover({ const dispatch = useAppDispatch(); const userId = useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; - const [name, setName] = useState(calendar?.name ?? ""); - const [description, setDescription] = useState(calendar?.description ?? ""); - const [color, setColor] = useState(calendar?.color ?? ""); + const calendars = useAppSelector((state) => state.calendars.list); + const isOwn = calendar ? calendar?.id.split("/")[0] === userId : true; + + // existing calendar params + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [color, setColor] = useState(""); + const [visibility, setVisibility] = useState<"private" | "public">("public"); + + // import tab state + const [tab, setTab] = useState<"settings" | "access" | "import">("settings"); + const [importedContent, setImportedContent] = useState(null); + const [importTarget, setImportTarget] = useState("new"); + + // new calendar params (for import new) + const [newCalName, setNewCalName] = useState(""); + const [newCalDescription, setNewCalDescription] = useState(""); + const [newCalColor, setNewCalColor] = useState(""); + const [newCalVisibility, setNewCalVisibility] = useState< + "public" | "private" + >("public"); useEffect(() => { - if (open) { - if (calendar) { - setName(calendar.name); - setDescription(calendar.description ?? ""); - setColor(calendar.color ?? ""); - } else { - setName(""); - setDescription(""); - setColor(""); - } + if (!open) return; + if (calendar) { + setName(calendar.name); + setDescription(calendar.description ?? ""); + setColor(calendar.color ?? ""); + setVisibility(calendar.visibility ?? "public"); + setImportTarget(calendar.id ?? "new"); + } else { + setName(""); + setDescription(""); + setColor(""); + setVisibility("public"); + setImportTarget("new"); } }, [calendar, open]); - const handleSave = () => { - const trimmedName = name.trim(); - const trimmedDesc = description.trim(); - - if (trimmedName) { - const calId = calendar ? calendar.id : crypto.randomUUID(); - - if (calendar?.id) { - dispatch( - patchCalendarAsync({ - calId: calendar.id, - calLink: calendar.link, - patch: { name: trimmedName, desc: trimmedDesc, color }, - }) - ); - } else { - dispatch( - createCalendarAsync({ - name: trimmedName, - desc: trimmedDesc, - color, - userId, - calId, - }) - ); - } - - onClose({}, "backdropClick"); + const updateCalendar = (calId: string, calLink: string) => { + dispatch( + patchCalendarAsync({ + calId, + calLink, + patch: { name: name.trim(), desc: description.trim(), color }, + }) + ); + if (visibility !== calendar?.visibility) { + dispatch( + patchACLCalendarAsync({ + calId, + calLink, + request: visibility === "public" ? "{DAV:}read" : "", + }) + ); } }; - return ( - onClose(e, reason)}> - - Calendar configuration - - - setName(e.target.value)} - size="small" - margin="dense" - /> - setDescription(e.target.value)} - size="small" - margin="dense" - multiline - rows={2} - /> - setColor(color)} - selectedColor={color} - /> - + const createCalendar = async ( + calId: string, + name: string, + desc: string, + color: string, + visibility: string + ) => { + await dispatch( + createCalendarAsync({ + name: name.trim(), + desc: desc.trim(), + color: color, + userId, + calId, + }) + ); + dispatch( + patchACLCalendarAsync({ + calId: `${userId}/${calId}`, + calLink: `/calendars/${userId}/${calId}.json`, + request: visibility === "public" ? "{DAV:}read" : "", + }) + ); + }; - - - - - + const handleSave = () => { + if (!name.trim()) return; + + if (calendar) { + updateCalendar(calendar.id, calendar.link); + } else { + createCalendar(crypto.randomUUID(), name, description, color, visibility); + } + handleClose({}, "backdropClick"); + }; + + const handleImport = async () => { + if (importTarget === "new") { + const calId = crypto.randomUUID(); + if (newCalName.trim()) { + await createCalendar( + calId, + newCalName, + newCalDescription, + newCalColor, + newCalVisibility + ); + importedContent && + dispatch( + importEventFromFileAsync({ + calLink: `/calendar/${userId}/${calId}.json`, + file: importedContent, + }) + ); + } + } else { + importedContent && + dispatch( + importEventFromFileAsync({ + calLink: calendars[importTarget].link, + file: importedContent, + }) + ); + } + handleClose({}, "backdropClick"); + }; + + const handleClose = ( + e: {}, + reason: "backdropClick" | "escapeKeyDown" + ): void => { + onClose(e, reason); + setName(""); + setDescription(""); + setColor(""); + setTab("settings"); + setVisibility("public"); + setImportTarget("new"); + setImportedContent(null); + + setNewCalName(""); + setNewCalDescription(""); + setNewCalColor(""); + setNewCalVisibility("public"); + }; + + return ( + handleClose({}, "backdropClick")} + title={ + setTab(v)}> + + {calendar && } + {isOwn && } + + } + actions={ + + + + + } + > + {tab === "import" && ( + + )} + {tab === "settings" && ( + + )} + {tab === "access" && calendar && } + ); } diff --git a/src/components/Calendar/ImportTab.tsx b/src/components/Calendar/ImportTab.tsx new file mode 100644 index 0000000..b7faa82 --- /dev/null +++ b/src/components/Calendar/ImportTab.tsx @@ -0,0 +1,123 @@ +import { + Box, + Button, + FormControl, + InputLabel, + MenuItem, + Select, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from "@mui/material"; +import { useEffect, useState } from "react"; +import { useAppSelector } from "../../app/hooks"; +import { SettingsTab } from "./SettingsTab"; + +export function ImportTab({ + userId, + importTarget, + setImportTarget, + setImportedContent, + newCalParams, +}: { + userId: string; + importTarget: string; + setImportTarget: Function; + setImportedContent: Function; + newCalParams: { + name: string; + setName: Function; + description: string; + setDescription: Function; + color: string; + setColor: Function; + visibility: "public" | "private"; + setVisibility: Function; + }; +}) { + const [importMode, setImportMode] = useState<"file" | "url">("file"); + const [importFile, setImportFile] = useState(null); + const [importUrl, setImportUrl] = useState(""); + const calendars = useAppSelector((state) => state.calendars.list); + const personnalCalendars = Object.keys(calendars).filter( + (id) => id.split("/")[0] === userId + ); + + useEffect(() => { + setImportedContent(importMode === "file" ? importFile : null); + }, [importFile, importUrl, importMode]); + + return ( + + val && setImportMode(val)} + fullWidth + size="small" + sx={{ mb: 2 }} + > + File + {/* URL */} + + + {importMode === "file" && ( + <> + + {importFile && ( + + {importFile.name} + + )} + + Import events from an ICS file to one of your calendars. + + + )} + + {importMode === "url" && ( + setImportUrl(e.target.value)} + size="small" + margin="dense" + /> + )} + + + Import to + + + + {importTarget === "new" && } + + ); +} diff --git a/src/components/Calendar/SettingsTab.tsx b/src/components/Calendar/SettingsTab.tsx new file mode 100644 index 0000000..27e81db --- /dev/null +++ b/src/components/Calendar/SettingsTab.tsx @@ -0,0 +1,113 @@ +import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted"; +import LockIcon from "@mui/icons-material/Lock"; +import PublicIcon from "@mui/icons-material/Public"; +import { + Box, + Button, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from "@mui/material"; +import { useState } from "react"; +import { useAppSelector } from "../../app/hooks"; +import { Calendars } from "../../features/Calendars/CalendarTypes"; +import { ColorPicker } from "./CalendarColorPicker"; + +export function SettingsTab({ + name, + setName, + description, + setDescription, + color, + setColor, + visibility, + setVisibility, + calendar, +}: { + name: string; + setName: Function; + description: string; + setDescription: Function; + color: string; + setColor: Function; + visibility: "public" | "private"; + setVisibility: Function; + calendar?: Calendars; +}) { + const [toggleDesc, setToggleDesc] = useState(Boolean(description)); + const userId = + useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; + const isOwn = calendar ? calendar.id.split("/")[0] === userId : true; + + return ( + + setName(e.target.value)} + size="small" + margin="dense" + /> + + {!toggleDesc && ( + + )} + + {toggleDesc && ( + setDescription(e.target.value)} + size="small" + margin="dense" + multiline + rows={2} + /> + )} + + + + Color + + setColor(color)} + selectedColor={color} + /> + + + {isOwn && ( + + + New events created will be visible to: + + val && setVisibility(val)} + size="small" + > + + + All + + + + + You + + + + )} + + ); +} diff --git a/src/components/Calendar/utils/calendarUtils.ts b/src/components/Calendar/utils/calendarUtils.ts index 70dd735..cf947df 100644 --- a/src/components/Calendar/utils/calendarUtils.ts +++ b/src/components/Calendar/utils/calendarUtils.ts @@ -133,3 +133,26 @@ export const updateCalsDetails = ( }); } }; + +interface AclEntry { + privilege: string; + principal: string; + protected: boolean; +} + +export function getCalendarVisibility(acl: AclEntry[]): "private" | "public" { + let hasRead = false; + let hasFreeBusy = false; + + for (const entry of acl) { + if (entry.principal !== "{DAV:}authenticated") continue; + + if (entry.privilege === "{DAV:}read") { + hasRead = true; + break; // highest visibility, can stop + } + } + + if (hasRead) return "public"; + return "private"; +} diff --git a/src/components/Loading/SnackBarAlert.tsx b/src/components/Loading/SnackBarAlert.tsx new file mode 100644 index 0000000..c5dbd08 --- /dev/null +++ b/src/components/Loading/SnackBarAlert.tsx @@ -0,0 +1,25 @@ +import Alert from "@mui/material/Alert"; +import Snackbar from "@mui/material/Snackbar"; + +export function SnackbarAlert({ + open, + setOpen, + message, +}: { + open: boolean; + setOpen: Function; + message: string; +}) { + return ( + setOpen(false)} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + > + setOpen(false)} sx={{ width: "100%" }}> + {message} + + + ); +} diff --git a/src/features/Calendars/CalendarApi.ts b/src/features/Calendars/CalendarApi.ts index cb7be1e..849920e 100644 --- a/src/features/Calendars/CalendarApi.ts +++ b/src/features/Calendars/CalendarApi.ts @@ -112,3 +112,14 @@ export async function removeCalendar(calLink: string) { }); return response; } + +export async function updateAclCalendar(calLink: string, request: string) { + const response = await api(`dav${calLink}`, { + method: "ACL", + headers: { + Accept: "application/json", + }, + body: JSON.stringify({ public_right: request }), + }); + return response; +} diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 234845e..e39d644 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -8,15 +8,24 @@ import { postCalendar, proppatchCalendar, removeCalendar, + updateAclCalendar, } from "./CalendarApi"; import { getOpenPaasUser, getUserDetails } from "../User/userAPI"; import { parseCalendarEvent } from "../Events/eventUtils"; -import { deleteEvent, getEvent, moveEvent, putEvent } from "../Events/EventApi"; import { - formatDateToYYYYMMDDTHHMMSS, + deleteEvent, + getEvent, + importEventFromFile, + moveEvent, + putEvent, +} from "../Events/EventApi"; +import { computeWeekRange, + formatDateToYYYYMMDDTHHMMSS, } from "../../utils/dateUtils"; import { User } from "../../components/Attendees/PeopleSearch"; +import { getCalendarVisibility } from "../../components/Calendar/utils/calendarUtils"; +import { importFile } from "../../utils/apiUtils"; export const getCalendarsListAsync = createAsyncThunk< Record // Return type @@ -39,7 +48,7 @@ export const getCalendarsListAsync = createAsyncThunk< delegated = true; } const id = source.replace("/calendars/", "").replace(".json", ""); - + const visibility = getCalendarVisibility(cal["acl"]); const ownerData: any = await getUserDetails(id.split("/")[0]); const color = cal["apple:color"]; importedCalendars[id] = { @@ -53,6 +62,7 @@ export const getCalendarsListAsync = createAsyncThunk< description, delegated, color, + visibility, events: {}, }; } @@ -82,6 +92,7 @@ export const getTempCalendarsListAsync = createAsyncThunk< const link = cal._links.self.href; const id = source.replace("/calendars/", "").replace(".json", ""); + const visibility = getCalendarVisibility(cal["acl"]); const ownerData: any = await getUserDetails(id.split("/")[0]); importedCalendars[id] = { @@ -93,6 +104,7 @@ export const getTempCalendarsListAsync = createAsyncThunk< description, delegated, color: tempUser.color ?? "#a8a8a8ff", + visibility, events: {}, }; } @@ -234,6 +246,26 @@ export const moveEventAsync = createAsyncThunk< }; }); +export const patchACLCalendarAsync = createAsyncThunk< + { + calId: string; + calLink: string; + request: string; + }, // Return type + { + calId: string; + calLink: string; + request: string; + } // Arg type +>("calendars/requestACLCalendar", async ({ calId, calLink, request }) => { + const response = await updateAclCalendar(calLink, request); + return { + calId, + calLink, + request, + }; +}); + export const deleteEventAsync = createAsyncThunk< { calId: string; eventId: string }, // Return type { calId: string; eventId: string; eventURL: string } // Arg type @@ -305,6 +337,17 @@ export const addSharedCalendarAsync = createAsyncThunk< }; }); +export const importEventFromFileAsync = createAsyncThunk< + void, + { + calLink: string; + file: File; + } +>("calendars/importEvent", async ({ calLink, file }) => { + const id = ((await importFile(file)) as Record)._id; + const response = await importEventFromFile(id, calLink); +}); + const CalendarSlice = createSlice({ name: "calendars", initialState: { @@ -507,6 +550,7 @@ const CalendarSlice = createSlice({ state.list[`${action.payload.userId}/${action.payload.calId}`] = { color: action.payload.color, id: `${action.payload.userId}/${action.payload.calId}`, + link: `/calendars/${action.payload.userId}/${action.payload.calId}.json`, description: action.payload.desc, name: action.payload.name, owner: action.payload.owner, @@ -557,6 +601,11 @@ const CalendarSlice = createSlice({ state.pending = false; delete state.list[action.payload.calId]; }) + .addCase(patchACLCalendarAsync.fulfilled, (state, action) => { + state.pending = false; + state.list[action.payload.calId].visibility = + action.payload.request !== "" ? "public" : "private"; + }) .addCase(getCalendarDetailAsync.pending, (state) => { state.pending = true; }) @@ -589,6 +638,9 @@ const CalendarSlice = createSlice({ }) .addCase(removeCalendarAsync.pending, (state) => { state.pending = true; + }) + .addCase(patchACLCalendarAsync.pending, (state) => { + state.pending = true; }); }, }); diff --git a/src/features/Calendars/CalendarTypes.ts b/src/features/Calendars/CalendarTypes.ts index 5692be7..d754680 100644 --- a/src/features/Calendars/CalendarTypes.ts +++ b/src/features/Calendars/CalendarTypes.ts @@ -13,4 +13,5 @@ export interface Calendars { calscale?: string; version?: string; events: Record; + visibility: "private" | "public"; } diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index 7a6186a..29a4bf0 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -53,3 +53,10 @@ export async function deleteEvent(eventURL: string) { }); return response; } + +export async function importEventFromFile(id: string, calLink: string) { + const response = await api.post(`api/import`, { + body: JSON.stringify({ fileId: id, target: calLink }), + }); + return response; +} diff --git a/src/utils/apiUtils.ts b/src/utils/apiUtils.ts index 7af4120..9706c60 100644 --- a/src/utils/apiUtils.ts +++ b/src/utils/apiUtils.ts @@ -54,3 +54,11 @@ export function isValidUrl(string?: string) { } return url; } + +export async function importFile(file: File) { + const response = await api.post( + `api/files?mimetype=${file.type}&name=${file.name}&size=${file.size}`, + { body: await file.text() } + ); + return await response.json(); +}