From cd724a30a0696c119bcd6232f3f11d94536221ee Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Mon, 22 Sep 2025 20:45:02 +0200 Subject: [PATCH] ability to register a shared calendar (#118) Co-authored-by: Camille Moussu --- .../components/CalendarSelection.test.tsx | 62 +++- .../features/Calendars/CalendarModal.test.tsx | 17 +- src/components/Attendees/AttendeeSearch.tsx | 122 ++----- src/components/Attendees/PeopleSearch.tsx | 106 ++++++ .../Calendar/CalendarColorPicker.tsx | 36 ++ src/components/Calendar/CalendarSearch.tsx | 336 ++++++++++++++++++ src/components/Calendar/CalendarSelection.tsx | 20 +- src/features/Calendars/CalendarApi.ts | 45 ++- src/features/Calendars/CalendarModal.tsx | 15 +- src/features/Calendars/CalendarSlice.ts | 78 +++- src/features/Events/EventDisplay.tsx | 5 +- src/features/User/userAPI.ts | 15 +- 12 files changed, 721 insertions(+), 136 deletions(-) create mode 100644 src/components/Attendees/PeopleSearch.tsx create mode 100644 src/components/Calendar/CalendarColorPicker.tsx create mode 100644 src/components/Calendar/CalendarSearch.tsx diff --git a/__test__/components/CalendarSelection.test.tsx b/__test__/components/CalendarSelection.test.tsx index 15fbe44..a91dbb7 100644 --- a/__test__/components/CalendarSelection.test.tsx +++ b/__test__/components/CalendarSelection.test.tsx @@ -1,4 +1,4 @@ -import { screen, fireEvent } from "@testing-library/react"; +import { screen, fireEvent, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; import CalendarSelection from "../../src/components/Calendar/CalendarSelection"; import { renderWithProviders } from "../utils/Renderwithproviders"; @@ -16,7 +16,7 @@ describe("CalendarSelection", () => { const calendarsMock = { "user1/cal1": { - name: "Calendar personnal", + name: "Calendar personal", id: "user1/cal1", color: "#FF0000", ownerEmails: ["alice@example.com"], @@ -36,7 +36,7 @@ describe("CalendarSelection", () => { }, }; - it("renders personal, delegated and shared calendars", () => { + it("renders personal, delegated and other calendars", () => { renderWithProviders( { expect(screen.getByText("Delegated Calendars")).toBeInTheDocument(); expect(screen.getByText("Other Calendars")).toBeInTheDocument(); - expect(screen.getByLabelText("Calendar personnal")).toBeChecked(); + expect(screen.getByLabelText("Calendar personal")).toBeChecked(); expect(screen.getByLabelText("Calendar delegated")).not.toBeChecked(); expect(screen.getByLabelText("Calendar shared")).not.toBeChecked(); }); @@ -71,7 +71,7 @@ describe("CalendarSelection", () => { } ); - const checkbox = screen.getByLabelText("Calendar personnal"); + const checkbox = screen.getByLabelText("Calendar personal"); fireEvent.click(checkbox); expect(setSelectedCalendars).toHaveBeenCalledWith(expect.any(Function)); @@ -94,14 +94,14 @@ describe("CalendarSelection", () => { } ); - const checkbox = screen.getByLabelText("Calendar personnal"); + const checkbox = screen.getByLabelText("Calendar personal"); fireEvent.click(checkbox); const updater = setSelectedCalendars.mock.calls[0][0]; expect(updater(["user1/cal1"])).toEqual([]); }); - it("opens CalendarPopover when Add button is clicked", () => { + it("opens CalendarPopover modal when personal Add button is clicked", () => { renderWithProviders( { } ); - const addButton = screen.getAllByTestId("AddIcon")[0]; - fireEvent.click(addButton); + const addButtons = screen.getAllByRole("button"); + fireEvent.click(addButtons[0]); + + waitFor(() => expect(screen.getByRole("presentation")).toBeInTheDocument()); + }); + + it("opens CalendarSearch modal when Other Add button is clicked", () => { + renderWithProviders( + , + { + user: baseUser, + calendars: { list: calendarsMock, pending: false }, + } + ); + + const addButtons = screen.getAllByRole("button"); + fireEvent.click(addButtons[1]); // seccond Add button (other) expect(screen.getByRole("presentation")).toBeInTheDocument(); }); - it("renders only personal calendars if no delegated/shared exist", () => { + it("renders only personal calendars if no delegated/other exist", () => { renderWithProviders( { expect(screen.getByText("Personnal Calendars")).toBeInTheDocument(); expect(screen.queryByText("Delegated Calendars")).not.toBeInTheDocument(); - expect(screen.queryByText("Shared Calendars")).not.toBeInTheDocument(); + expect(screen.queryByText("Other Calendars")).not.toBeInTheDocument(); }); it("renders nothing when no calendars are present", () => { @@ -155,4 +173,26 @@ describe("CalendarSelection", () => { expect(screen.queryByLabelText(/Calendar/)).not.toBeInTheDocument(); }); + + it("expands and collapses accordions when clicked", () => { + renderWithProviders( + , + { + user: baseUser, + calendars: { list: calendarsMock, pending: false }, + } + ); + const delegatedAccordionSummary = screen + .getByText("Delegated Calendars") + .closest(".MuiAccordionSummary-root"); + + fireEvent.click(delegatedAccordionSummary!); + expect(delegatedAccordionSummary).toHaveAttribute("aria-expanded", "false"); + + fireEvent.click(delegatedAccordionSummary!); + expect(delegatedAccordionSummary).toHaveAttribute("aria-expanded", "true"); + }); }); diff --git a/__test__/features/Calendars/CalendarModal.test.tsx b/__test__/features/Calendars/CalendarModal.test.tsx index dfa8af3..0fcdfb9 100644 --- a/__test__/features/Calendars/CalendarModal.test.tsx +++ b/__test__/features/Calendars/CalendarModal.test.tsx @@ -56,15 +56,15 @@ describe("CalendarPopover", () => { renderPopover(); // There are multiple color buttons; pick the first - const colorButtons = screen - .getAllByRole("button") - .filter((btn) => btn.style.backgroundColor !== ""); + 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(colorButtons[0].style.backgroundColor); + ).toBe("rgb(52, 211, 153)"); }); it("dispatches createCalendar and calls onClose when Save clicked", () => { @@ -82,12 +82,9 @@ describe("CalendarPopover", () => { target: { value: "Test Description" }, }); - const expectedColor = "#D50000"; - - const colorButtons = screen - .getAllByRole("button") - .filter((btn) => btn.style.backgroundColor !== ""); - + const colorButtons = screen.getAllByRole("button", { + name: /select color/i, + }); fireEvent.click(colorButtons[0]); fireEvent.click(screen.getByText(/Save/i)); diff --git a/src/components/Attendees/AttendeeSearch.tsx b/src/components/Attendees/AttendeeSearch.tsx index 641579f..71cf6d9 100644 --- a/src/components/Attendees/AttendeeSearch.tsx +++ b/src/components/Attendees/AttendeeSearch.tsx @@ -1,21 +1,7 @@ -import { - Autocomplete, - Avatar, - CircularProgress, - ListItem, - ListItemAvatar, - ListItemText, - TextField, -} from "@mui/material"; import { useEffect, useState } from "react"; import { searchUsers } from "../../features/User/userAPI"; import { userAttendee } from "../../features/User/userDataTypes"; - -interface User { - email: string; - displayName: string; - avatarUrl: string; -} +import { PeopleSearch, User } from "./PeopleSearch"; export default function UserSearch({ attendees, @@ -26,77 +12,43 @@ export default function UserSearch({ setAttendees: Function; disabled?: boolean; }) { - const [query, setQuery] = useState(""); - const [options, setOptions] = useState([]); - const [loading, setLoading] = useState(false); - + const [selectedUsers, setSelectedUsers] = useState( + attendees.map((a) => ({ + email: a.cal_address, + displayName: a.cn ?? "", + avatarUrl: "", + openpaasId: "", + })) + ); useEffect(() => { - const delayDebounceFn = setTimeout(async () => { - if (query) { - setLoading(true); - const res = await searchUsers(query); - setOptions(res); - setLoading(false); - } - }, 300); - - return () => clearTimeout(delayDebounceFn); - }, [query]); - + setSelectedUsers( + attendees.map((a) => ({ + email: a.cal_address, + displayName: a.cn ?? "", + avatarUrl: "", + openpaasId: "", + })) + ); + }, [attendees]); return ( - x} - fullWidth - getOptionLabel={(option) => option.displayName || option.email} - filterSelectedOptions - onInputChange={(event, value) => setQuery(value)} - onChange={(event, value) => - setAttendees( - value.map((a) => ({ - cn: a.displayName, - cal_address: a.email, - partstat: "NEED_ACTION", - rsvp: "FALSE", - role: "CHAIR", - cutype: "INDIVIDUAL", - })) - ) - } - renderInput={(params) => ( - - {loading ? ( - - ) : null} - {params.InputProps.endAdornment} - - ), - }} - /> - )} - renderOption={(props, option) => { - if (attendees.find((a) => a.cal_address === option.email)) return; - return ( - - - - - - - ); - }} - /> + <> + { + setAttendees( + value.map((a: User) => ({ + cn: a.displayName, + cal_address: a.email, + partstat: "NEED_ACTION", + rsvp: "FALSE", + role: "CHAIR", + cutype: "INDIVIDUAL", + })) + ); + setSelectedUsers(value); + }} + /> + ); } diff --git a/src/components/Attendees/PeopleSearch.tsx b/src/components/Attendees/PeopleSearch.tsx new file mode 100644 index 0000000..6406cfa --- /dev/null +++ b/src/components/Attendees/PeopleSearch.tsx @@ -0,0 +1,106 @@ +import CloseIcon from "@mui/icons-material/Close"; +import Autocomplete from "@mui/material/Autocomplete"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import CardActions from "@mui/material/CardActions"; +import CardContent from "@mui/material/CardContent"; +import CardHeader from "@mui/material/CardHeader"; +import CircularProgress from "@mui/material/CircularProgress"; +import IconButton from "@mui/material/IconButton"; +import ListItem from "@mui/material/ListItem"; +import ListItemAvatar from "@mui/material/ListItemAvatar"; +import ListItemText from "@mui/material/ListItemText"; +import Modal from "@mui/material/Modal"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { useState, useEffect } from "react"; +import { searchUsers } from "../../features/User/userAPI"; + +export interface User { + email: string; + displayName: string; + avatarUrl: string; + openpaasId: string; +} + +export function PeopleSearch({ + selectedUsers, + onChange, + disabled, +}: { + selectedUsers: User[]; + onChange: Function; + disabled?: boolean; +}) { + const [query, setQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [options, setOptions] = useState([]); + + useEffect(() => { + const delayDebounceFn = setTimeout(async () => { + if (query) { + setLoading(true); + const res = await searchUsers(query, ["user"]); + setOptions(res); + setLoading(false); + } + }, 300); + + return () => clearTimeout(delayDebounceFn); + }, [query]); + + return ( + x} + fullWidth + getOptionLabel={(option) => option.displayName || option.email} + filterSelectedOptions + value={selectedUsers} + onInputChange={(event, value) => setQuery(value)} + onChange={(event, value) => onChange(event, value)} + renderInput={(params) => ( + + {loading ? ( + + ) : null} + {params.InputProps.endAdornment} + + ), + }, + }} + /> + )} + renderOption={(props, option) => { + if (selectedUsers.find((u) => u.email === option.email)) return; + return ( + + + + + + + ); + }} + /> + ); +} diff --git a/src/components/Calendar/CalendarColorPicker.tsx b/src/components/Calendar/CalendarColorPicker.tsx new file mode 100644 index 0000000..ac900fa --- /dev/null +++ b/src/components/Calendar/CalendarColorPicker.tsx @@ -0,0 +1,36 @@ +import { Box } from "@mui/material"; + +const defaultColors = ["#34d399", "#fbbf24", "#f87171", "#60a5fa", "#f472b6"]; + +export function ColorPicker({ + selectedColor, + colors = defaultColors, + onChange, +}: { + selectedColor?: string; + colors?: string[]; + onChange: (color: string) => void; +}) { + return ( + + {colors.map((c) => ( + onChange(c)} + sx={{ + width: 20, + height: 20, + borderRadius: "50%", + bgcolor: c, + cursor: "pointer", + border: + selectedColor === c ? "2px solid black" : "2px solid transparent", + transition: "all 0.2s", + }} + /> + ))} + + ); +} diff --git a/src/components/Calendar/CalendarSearch.tsx b/src/components/Calendar/CalendarSearch.tsx new file mode 100644 index 0000000..9fe14f0 --- /dev/null +++ b/src/components/Calendar/CalendarSearch.tsx @@ -0,0 +1,336 @@ +import CloseIcon from "@mui/icons-material/Close"; +import Autocomplete from "@mui/material/Autocomplete"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import CardActions from "@mui/material/CardActions"; +import CardContent from "@mui/material/CardContent"; +import CardHeader from "@mui/material/CardHeader"; +import CircularProgress from "@mui/material/CircularProgress"; +import IconButton from "@mui/material/IconButton"; +import ListItem from "@mui/material/ListItem"; +import ListItemAvatar from "@mui/material/ListItemAvatar"; +import ListItemText from "@mui/material/ListItemText"; +import Modal from "@mui/material/Modal"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { useState } from "react"; +import { useAppDispatch, useAppSelector } from "../../app/hooks"; +import { searchUsers } from "../../features/User/userAPI"; +import { getCalendars } from "../../features/Calendars/CalendarApi"; +import { addSharedCalendarAsync } from "../../features/Calendars/CalendarSlice"; +import { ColorPicker } from "./CalendarColorPicker"; +import { Calendars } from "../../features/Calendars/CalendarTypes"; +import { User, PeopleSearch } from "../Attendees/PeopleSearch"; + +interface CalendarWithOwner { + cal: Record; + owner: User; +} + +function CalendarItem({ + cal, + onRemove, + onColorChange, +}: { + cal: CalendarWithOwner; + onRemove: () => void; + onColorChange: (color: string) => void; +}) { + return ( + + + + + {cal.cal["dav:name"]} + + {cal.owner.email} + + + + + + + + + + + + ); +} + +function SelectedCalendarsList({ + calendars, + selectedCal, + onRemove, + onColorChange, +}: { + calendars: Record; + selectedCal: CalendarWithOwner[]; + onRemove: (cal: CalendarWithOwner) => void; + onColorChange: (cal: CalendarWithOwner, color: string) => void; +}) { + if (selectedCal.length === 0) return null; + + const groupedByOwner = selectedCal.reduce< + Record< + string, + { + owner: User; + visibleCals: CalendarWithOwner[]; + alreadyExisting: boolean; + } + > + >((acc, cal) => { + const exists = Object.values(calendars).some( + (existing: any) => + existing.id === + cal.cal?._links?.self?.href + .replace("/calendars/", "") + .replace(".json", "") + ); + + if (!acc[cal.owner.email]) { + acc[cal.owner.email] = { + owner: cal.owner, + visibleCals: [], + alreadyExisting: false, + }; + } + + if (exists) { + acc[cal.owner.email].alreadyExisting = true; + } else { + acc[cal.owner.email].visibleCals.push(cal); + } + + return acc; + }, {}); + + return ( + + + Name + + + {Object.values(groupedByOwner).map( + ({ owner, visibleCals, alreadyExisting }) => ( + + {visibleCals.length > 0 ? ( + visibleCals.map((cal) => + cal.cal ? ( + onRemove(cal)} + onColorChange={(color) => onColorChange(cal, color)} + /> + ) : ( + + No publicly available calendars for {owner.displayName} + + ) + ) + ) : alreadyExisting ? ( + + No more Calendar for {owner.displayName} + + ) : null} + + ) + )} + + ); +} + +export default function CalendarSearch({ + anchorEl, + open, + onClose, +}: { + anchorEl: HTMLElement | null; + open: boolean; + onClose: Function; +}) { + const dispatch = useAppDispatch(); + const openpaasId = + useAppSelector((state) => state.user.userData.openpaasId) ?? ""; + const calendars = useAppSelector((state) => state.calendars.list); + + const [selectedCal, setSelectedCalendars] = useState([]); + const [selectedUsers, setSelectedUsers] = useState([]); + + const handleSave = async () => { + if (selectedCal.length > 0) { + const idList = await Promise.all( + selectedCal.map(async (cal) => { + const calId = crypto.randomUUID(); + const exists = Object.values(calendars).some( + (existing: any) => + existing.id === + cal.cal?._links?.self?.href + .replace("/calendars/", "") + .replace(".json", "") + ); + if (!exists && cal.cal) { + await dispatch( + addSharedCalendarAsync({ + userId: openpaasId, + calId, + cal: { ...cal, color: cal.cal["apple:color"] }, + }) + ); + return cal.cal._links.self.href + .replace("/calendars/", "") + .replace(".json", ""); + } + return null; + }) + ); + + onClose(idList.filter(Boolean)); + setSelectedCalendars([]); + setSelectedUsers([]); + } + }; + + return ( + { + onClose({}, "backdropClick"); + setSelectedCalendars([]); + setSelectedUsers([]); + }} + > + + + + onClose({}, "backdropClick")} + > + + + + + + + { + setSelectedUsers(value); + + const cals = await Promise.all( + value.map(async (user: User) => { + const cals = (await getCalendars( + user.openpaasId, + "sharedPublic=true&withRights=true" + )) as Record; + return cals._embedded?.["dav:calendar"] + ? cals._embedded["dav:calendar"].map( + (cal: Record) => ({ cal, owner: user }) + ) + : { cal: undefined, owner: user }; + }) + ); + + setSelectedCalendars(cals.flat()); + }} + /> + + { + setSelectedCalendars((prev) => + prev.filter( + (c) => c.cal._links.self.href !== cal.cal._links.self.href + ) + ); + if ( + !selectedCal.find( + (c) => + cal.owner.email === c.owner.email && + c.cal._links.self.href !== cal.cal._links.self.href + ) + ) { + setSelectedUsers((prev) => + prev.filter((u) => u.email !== cal.owner.email) + ); + } + }} + onColorChange={(cal, color) => + setSelectedCalendars((prev) => + prev.map((prevcal) => + prevcal.owner.email === cal.owner.email && + prevcal.cal._links.self.href === cal.cal._links.self.href + ? { + ...prevcal, + cal: { ...prevcal.cal, "apple:color": color }, + } + : prevcal + ) + ) + } + /> + + + + + + + + + + ); +} diff --git a/src/components/Calendar/CalendarSelection.tsx b/src/components/Calendar/CalendarSelection.tsx index 1734226..15e6401 100644 --- a/src/components/Calendar/CalendarSelection.tsx +++ b/src/components/Calendar/CalendarSelection.tsx @@ -72,6 +72,8 @@ function CalendarAccordion({ ); } +import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; +import CalendarSearch from "./CalendarSearch"; export default function CalendarSelection({ selectedCalendars, @@ -102,6 +104,9 @@ export default function CalendarSelection({ const [selectedCalId, setSelectedCalId] = useState(""); const [anchorElCal, setAnchorElCal] = useState(null); + const [anchorElCalOthers, setAnchorElCalOthers] = + useState(null); + return ( <>
@@ -137,7 +142,7 @@ export default function CalendarSelection({ selectedCalendars={selectedCalendars} showAddButton onAddClick={() => { - setAnchorElCal(document.body); + setAnchorElCalOthers(document.body); }} handleToggle={handleCalendarToggle} setOpen={(id: string) => { @@ -146,15 +151,24 @@ export default function CalendarSelection({ }} />
- { setSelectedCalId(""); setAnchorElCal(null); }} - calendar={calendars[selectedCalId] ?? undefined} + /> + { + setAnchorElCalOthers(null); + if (newCalIds?.length) { + newCalIds.forEach((id) => handleCalendarToggle(id)); + } + }} /> ); diff --git a/src/features/Calendars/CalendarApi.ts b/src/features/Calendars/CalendarApi.ts index 5ae9f59..8509879 100644 --- a/src/features/Calendars/CalendarApi.ts +++ b/src/features/Calendars/CalendarApi.ts @@ -1,15 +1,15 @@ import { api } from "../../utils/apiUtils"; -export async function getCalendars(userId: string) { +export async function getCalendars( + userId: string, + scope: string = "personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&withRights=true" +) { const calendars = await api - .get( - `dav/calendars/${userId}.json?personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&withRights=true`, - { - headers: { - Accept: "application/calendar+json", - }, - } - ) + .get(`dav/calendars/${userId}.json?${scope}`, { + headers: { + Accept: "application/calendar+json", + }, + }) .json(); return calendars; } @@ -52,6 +52,33 @@ export async function postCalendar( return response; } +export async function addSharedCalendar( + userId: string, + calId: string, + cal: Record +) { + const response = await api.post(`dav/calendars/${userId}.json`, { + headers: { + Accept: "application/json, text/plain, */*", + }, + body: JSON.stringify({ + id: calId, + ...cal.cal, + "calendarserver:source": { + acl: cal.cal.acl, + calendarHomeId: cal.cal.id, + color: cal.cal["apple:color"], + description: cal.cal["caldav:description"], + href: cal.cal._links.self.href, + id: cal.cal.id, + invite: cal.cal.invite, + name: cal.cal["dav:name"], + }, + }), + }); + return response; +} + export async function proppatchCalendar( calLink: string, patch: { name: string; desc: string; color: string } diff --git a/src/features/Calendars/CalendarModal.tsx b/src/features/Calendars/CalendarModal.tsx index d53c3c1..0accba4 100644 --- a/src/features/Calendars/CalendarModal.tsx +++ b/src/features/Calendars/CalendarModal.tsx @@ -12,6 +12,7 @@ import { Typography, ButtonGroup, } from "@mui/material"; +import { ColorPicker } from "../../components/Calendar/CalendarColorPicker"; import { Calendars } from "./CalendarTypes"; function CalendarPopover({ @@ -128,15 +129,11 @@ function CalendarPopover({ multiline rows={2} /> - - {palette.map((c) => ( -