From f640cac8acf7005b0472a9046ae252503a8cdf23 Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Wed, 17 Sep 2025 15:05:27 +0200 Subject: [PATCH] update cal details (#113) * [#65] added button to access cal details * [#65] added proppatch call * [#65] added tests * fixup! [#65] added proppatch call * [#65] added trimming to name and desc * fixup! [#65] added proppatch call * fixup! [#65] added proppatch call * fixup! [#65] added proppatch call --------- Co-authored-by: Camille Moussu --- .../components/CalendarSelection.test.tsx | 2 +- .../features/Calendars/CalendarAPI.test.tsx | 22 ++++ .../features/Calendars/CalendarModal.test.tsx | 118 ++++++++++++++++-- src/components/Calendar/CalendarSelection.tsx | 118 +++++++++++------- src/features/Calendars/CalendarApi.ts | 24 ++++ src/features/Calendars/CalendarModal.tsx | 99 ++++++++++----- src/features/Calendars/CalendarSlice.ts | 59 ++++++++- src/features/Calendars/CalendarTypes.ts | 1 + 8 files changed, 358 insertions(+), 85 deletions(-) diff --git a/__test__/components/CalendarSelection.test.tsx b/__test__/components/CalendarSelection.test.tsx index 56c6e25..e3ad35f 100644 --- a/__test__/components/CalendarSelection.test.tsx +++ b/__test__/components/CalendarSelection.test.tsx @@ -113,7 +113,7 @@ describe("CalendarSelection", () => { } ); - const addButton = screen.getByRole("button"); + const addButton = screen.getByTestId("AddIcon"); fireEvent.click(addButton); expect(screen.getByRole("presentation")).toBeInTheDocument(); diff --git a/__test__/features/Calendars/CalendarAPI.test.tsx b/__test__/features/Calendars/CalendarAPI.test.tsx index b4724eb..c72a2c8 100644 --- a/__test__/features/Calendars/CalendarAPI.test.tsx +++ b/__test__/features/Calendars/CalendarAPI.test.tsx @@ -4,6 +4,7 @@ import { getCalendar, getCalendars, postCalendar, + proppatchCalendar, } from "../../../src/features/Calendars/CalendarApi"; import { clientConfig } from "../../../src/features/User/oidcAuth"; import { api } from "../../../src/utils/apiUtils"; @@ -77,4 +78,25 @@ describe("Calendar API", () => { }), }); }); + it("patch Calendar", async () => { + const calId = "calId"; + const calLink = "/calendars/calId.json"; + const color = "calId"; + const name = "new cal"; + const desc = "desc"; + + const result = await proppatchCalendar(calLink, { color, name, desc }); + + expect(api).toHaveBeenCalledWith(`dav${calLink}`, { + method: "PROPPATCH", + headers: { + Accept: "application/json, text/plain, */*", + }, + body: JSON.stringify({ + "dav:name": "new cal", + "caldav:description": "desc", + "apple:color": "calId", + }), + }); + }); }); diff --git a/__test__/features/Calendars/CalendarModal.test.tsx b/__test__/features/Calendars/CalendarModal.test.tsx index dc3bc6c..dfa8af3 100644 --- a/__test__/features/Calendars/CalendarModal.test.tsx +++ b/__test__/features/Calendars/CalendarModal.test.tsx @@ -1,6 +1,4 @@ -import { screen, fireEvent } from "@testing-library/react"; -import { useAppDispatch } from "../../../src/app/hooks"; -import { createCalendar } from "../../../src/features/Calendars/CalendarSlice"; +import { screen, fireEvent, waitFor } from "@testing-library/react"; import CalendarPopover from "../../../src/features/Calendars/CalendarModal"; import { renderWithProviders } from "../../utils/Renderwithproviders"; import * as eventThunks from "../../../src/features/Calendars/CalendarSlice"; @@ -97,10 +95,6 @@ describe("CalendarPopover", () => { expect(spy).toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); - - // Inputs should be reset (optional check) - expect(screen.getByLabelText(/Name/i)).toHaveValue(""); - expect(screen.getByLabelText(/Description/i)).toHaveValue(""); }); it("calls onClose when Cancel clicked", () => { @@ -111,3 +105,113 @@ describe("CalendarPopover", () => { expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); }); }); + +describe("CalendarPopover (editing mode)", () => { + const mockOnClose = jest.fn(); + + const baseUser = { + userData: { + sub: "test", + email: "test@test.com", + sid: "mockSid", + openpaasId: "user1", + }, + }; + + const existingCalendar = { + id: "user1/cal1", + link: "/calendars/user/cal1", + name: "Work Calendar", + description: "Team meetings", + color: "#33B679", + owner: "alice", + ownerEmails: ["alice@example.com"], + events: {}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("prefills fields when calendar prop is given", () => { + renderWithProviders( + , + { user: baseUser } + ); + + 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", () => { + renderWithProviders( + , + { user: baseUser } + ); + + const saveButton = screen.getByRole("button", { name: /save/i }); + expect(saveButton).toBeDisabled(); + // only spaces + const nameInput = screen.getByLabelText(/name/i); + fireEvent.change(nameInput, { target: { value: " " } }); + + expect(saveButton).toBeDisabled(); + + // valid name + fireEvent.change(nameInput, { target: { value: "Work Calendar" } }); + expect(saveButton).toBeEnabled(); + }); + + it("allows modifying and saving existing calendar", async () => { + const spy = jest + .spyOn(eventThunks, "patchCalendarAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + + renderWithProviders( + , + { user: baseUser } + ); + + // Change name + fireEvent.change(screen.getByLabelText(/Name/i), { + target: { value: "Updated Calendar" }, + }); + + // Save + fireEvent.click(screen.getByText(/Save/i)); + + await waitFor(() => + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + calId: "user1/cal1", + calLink: "/calendars/user/cal1", + patch: { + color: "#33B679", + desc: "Team meetings", + name: "Updated Calendar", + }, + }) + ) + ); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/src/components/Calendar/CalendarSelection.tsx b/src/components/Calendar/CalendarSelection.tsx index 2c2cc11..c6167e7 100644 --- a/src/components/Calendar/CalendarSelection.tsx +++ b/src/components/Calendar/CalendarSelection.tsx @@ -1,8 +1,12 @@ -import { Button } from "@mui/material"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import AddIcon from "@mui/icons-material/Add"; import { useEffect, useState } from "react"; import CalendarPopover from "../../features/Calendars/CalendarModal"; +import { Calendars } from "../../features/Calendars/CalendarTypes"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import IconButton from "@mui/material/IconButton"; +import Checkbox from "@mui/material/Checkbox"; +import Button from "@mui/material/Button"; export default function CalendarSelection({ selectedCalendars, @@ -27,9 +31,9 @@ export default function CalendarSelection({ prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name] ); }; + const [selectedCalId, setSelectedCalId] = useState(""); const [anchorElCal, setAnchorElCal] = useState(null); - return ( <>
@@ -39,39 +43,35 @@ export default function CalendarSelection({
- {personnalCalendars.map((id) => { - return ( -
- -
- ); - })} + {personnalCalendars.map((id) => + CalendarSelector( + calendars, + id, + selectedCalendars, + handleCalendarToggle, + () => { + setAnchorElCal(document.body); + setSelectedCalId(id); + } + ) + )} {delegatedCalendars.length > 0 && ( <>

Delegated Calendars

- {delegatedCalendars.map((id) => ( -
- -
- ))} + {delegatedCalendars.map((id) => + CalendarSelector( + calendars, + id, + selectedCalendars, + handleCalendarToggle, + () => { + setAnchorElCal(document.body); + setSelectedCalId(id); + } + ) + )} )} {sharedCalendars.length > 0 && ( @@ -79,27 +79,57 @@ export default function CalendarSelection({

Shared Calendars

- {sharedCalendars.map((id) => ( -
- -
- ))} + {sharedCalendars.map((id) => + CalendarSelector( + calendars, + id, + selectedCalendars, + handleCalendarToggle, + () => { + setAnchorElCal(document.body); + setSelectedCalId(id); + } + ) + )} )} setAnchorElCal(null)} + onClose={() => { + setAnchorElCal(null); + }} + calendar={calendars[selectedCalId] ?? undefined} /> ); } + +function CalendarSelector( + calendars: Record, + id: string, + selectedCalendars: string[], + handleCalendarToggle: (name: string) => void, + setOpen: Function +) { + return ( +
+ + setOpen()}> + + +
+ ); +} diff --git a/src/features/Calendars/CalendarApi.ts b/src/features/Calendars/CalendarApi.ts index 9cf8035..5ae9f59 100644 --- a/src/features/Calendars/CalendarApi.ts +++ b/src/features/Calendars/CalendarApi.ts @@ -51,3 +51,27 @@ export async function postCalendar( }); return response; } + +export async function proppatchCalendar( + calLink: string, + patch: { name: string; desc: string; color: string } +) { + const body: Record = {}; + if (patch.name) { + body["dav:name"] = patch.name; + } + if (patch.desc) { + body["caldav:description"] = patch.desc; + } + if (patch.color) { + body["apple:color"] = patch.color; + } + const response = await api(`dav${calLink}`, { + method: "PROPPATCH", + headers: { + Accept: "application/json, text/plain, */*", + }, + body: JSON.stringify(body), + }); + return response; +} diff --git a/src/features/Calendars/CalendarModal.tsx b/src/features/Calendars/CalendarModal.tsx index 05c2307..623716b 100644 --- a/src/features/Calendars/CalendarModal.tsx +++ b/src/features/Calendars/CalendarModal.tsx @@ -1,5 +1,8 @@ -import { useState } from "react"; -import { createCalendar, createCalendarAsync } from "./CalendarSlice"; +import { useEffect, useState } from "react"; +import { + createCalendarAsync /*, updateCalendarAsync */, + patchCalendarAsync, +} from "./CalendarSlice"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { Popover, @@ -7,38 +10,76 @@ import { Button, Box, Typography, - Select, ButtonGroup, } from "@mui/material"; +import { Calendars } from "./CalendarTypes"; function CalendarPopover({ anchorEl, open, onClose, + calendar, }: { anchorEl: HTMLElement | null; open: boolean; - onClose: (Calendar: {}, reason: "backdropClick" | "escapeKeyDown") => void; + onClose: ( + event: object | null, + reason: "backdropClick" | "escapeKeyDown" + ) => void; + calendar?: Calendars; }) { const dispatch = useAppDispatch(); const userId = useAppSelector((state) => state.user.userData.openpaasId) ?? ""; - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const [color, setColor] = useState(""); - const handleSave = () => { - const calId = crypto.randomUUID(); - if (name) { - dispatch( - createCalendarAsync({ name, desc: description, color, userId, calId }) - ); - onClose({}, "backdropClick"); + const [name, setName] = useState(calendar?.name ?? ""); + const [description, setDescription] = useState(calendar?.description ?? ""); + const [color, setColor] = useState(calendar?.color ?? ""); - // Reset - setName(""); - setDescription(""); + useEffect(() => { + if (open) { + if (calendar) { + setName(calendar.name); + setDescription(calendar.description ?? ""); + setColor(calendar.color ?? ""); + } else { + setName(""); + setDescription(""); + setColor(""); + } + } + }, [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 palette = [ "#D50000", "#E67C73", @@ -52,19 +93,14 @@ function CalendarPopover({ "#8E24AA", "#616161", ]; + return ( onClose(e, reason)} + anchorOrigin={{ vertical: "center", horizontal: "center" }} + transformOrigin={{ vertical: "center", horizontal: "center" }} > - {palette.map((color) => ( + {palette.map((c) => (