Revamp calendar dialog (#158)

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2025-10-07 11:14:08 +02:00
committed by GitHub
parent 2fa1dbb76b
commit 40c383c334
16 changed files with 1306 additions and 132 deletions
@@ -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(
<CalendarPopover open={true} onClose={mockOnClose} />,
{ 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(<CalendarPopover open={true} onClose={mockOnClose} />);
expect(screen.getByLabelText(/Name/i)).toHaveValue("");
});
it("shows Access tab only when editing an existing calendar", () => {
renderWithProviders(
<CalendarPopover
open={true}
onClose={mockOnClose}
calendar={existingCalendar}
/>,
{ user: baseUser }
);
expect(screen.getByRole("tab", { name: /Access/i })).toBeInTheDocument();
});
it("does not show Access tab when creating new calendar", () => {
renderWithProviders(<CalendarPopover open={true} onClose={mockOnClose} />, {
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(
<CalendarPopover
open={true}
onClose={mockOnClose}
calendar={existingCalendar}
/>,
{ 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(
<CalendarPopover
open={true}
onClose={mockOnClose}
calendar={existingCalendar}
/>,
{ 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(
<CalendarPopover open={true} onClose={mockOnClose} />,
{
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(
<CalendarPopover
open={true}
onClose={mockOnClose}
calendar={existingCalendar}
/>,
{ 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(
<CalendarPopover open={true} onClose={mockOnClose} />,
{
user: baseUser,
}
);
fireEvent.click(screen.getByRole("tab", { name: /Import/i }));
const importButton = screen.getByRole("button", { name: /Import/i });
expect(importButton).toBeDisabled();
});
});
});
@@ -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");
});
});
});
+25 -7
View File
@@ -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);