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 (
-
+ 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();
+}