From 475ab797313b54eaf1e4391906790170ef152663 Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Tue, 30 Sep 2025 10:27:47 +0200 Subject: [PATCH] Delete Calendars (#148) * [#66] added delete popup and logic * [#66] Homogenize Dialogs components between modify and delete to keep them coherent+ fixed tests * [#66] added tests * [#66] text adjusment * fixup! [#66] added delete popup and logic --------- Co-authored-by: Camille Moussu --- .../components/CalendarSelection.test.tsx | 79 ++++++++++- .../features/Calendars/CalendarAPI.test.tsx | 13 ++ .../features/Calendars/CalendarModal.test.tsx | 21 +-- src/components/Calendar/Calendar.tsx | 11 +- .../Calendar}/CalendarModal.tsx | 70 ++++------ src/components/Calendar/CalendarSelection.tsx | 128 ++++++++++++------ .../Calendar/DeleteCalendarDialog.tsx | 43 ++++++ .../Calendar/utils/calendarUtils.ts | 33 +++-- src/features/Calendars/CalendarApi.ts | 10 ++ src/features/Calendars/CalendarSlice.ts | 27 ++++ 10 files changed, 324 insertions(+), 111 deletions(-) rename src/{features/Calendars => components/Calendar}/CalendarModal.tsx (70%) create mode 100644 src/components/Calendar/DeleteCalendarDialog.tsx diff --git a/__test__/components/CalendarSelection.test.tsx b/__test__/components/CalendarSelection.test.tsx index e0e59b7..70754c9 100644 --- a/__test__/components/CalendarSelection.test.tsx +++ b/__test__/components/CalendarSelection.test.tsx @@ -1,7 +1,9 @@ -import { screen, fireEvent, waitFor } from "@testing-library/react"; +import { screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; import "@testing-library/jest-dom"; import CalendarSelection from "../../src/components/Calendar/CalendarSelection"; import { renderWithProviders } from "../utils/Renderwithproviders"; +import * as calendarThunks from "../../src/features/Calendars/CalendarSlice"; +import userEvent from "@testing-library/user-event"; describe("CalendarSelection", () => { const baseUser = { @@ -35,7 +37,10 @@ describe("CalendarSelection", () => { ownerEmails: ["charlie@example.com"], }, }; - + beforeAll(() => { + jest.clearAllMocks(); + cleanup(); + }); it("renders personal, delegated and other calendars", () => { renderWithProviders( { expect(updater(["user1/cal1"])).toEqual([]); }); - it("opens CalendarPopover modal when personal Add button is clicked", () => { + it("opens CalendarPopover modal when personal Add button is clicked", async () => { renderWithProviders( { ); const addButtons = screen.getAllByRole("button"); + fireEvent.click(addButtons[1]); + + await waitFor(() => + expect(screen.getByText("Calendar configuration")).toBeInTheDocument() + ); + }); + + it("Navigates to deletion dialog and deletes personnal cal", async () => { + const spy = jest + .spyOn(calendarThunks, "removeCalendarAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + renderWithProviders( + , + { + user: baseUser, + calendars: { list: calendarsMock, pending: false }, + } + ); + + const addButtons = screen.getAllByTestId("MoreVertIcon"); fireEvent.click(addButtons[0]); - waitFor(() => expect(screen.getByRole("presentation")).toBeInTheDocument()); + userEvent.click(screen.getByText(/delete/i)); + + await waitFor(() => + expect(screen.getByText("Remove Calendar personal?")).toBeInTheDocument() + ); + fireEvent.click(screen.getByRole("button", { name: /delete/i })); + + await waitFor(() => expect(spy).toHaveBeenCalled()); + }); + + it("Navigates to deletion dialog and deletes other cal", async () => { + const spy = jest + .spyOn(calendarThunks, "removeCalendarAsync") + .mockImplementation((payload) => { + return () => Promise.resolve(payload) as any; + }); + renderWithProviders( + , + { + user: baseUser, + calendars: { list: calendarsMock, pending: false }, + } + ); + + const addButtons = screen.getAllByTestId("MoreVertIcon"); + fireEvent.click(addButtons[1]); + + userEvent.click(screen.getByText(/remove/i)); + + await waitFor(() => + expect(screen.getByText("Remove Calendar delegated?")).toBeInTheDocument() + ); + fireEvent.click(screen.getByRole("button", { name: /remove/i })); + + await waitFor(() => expect(spy).toHaveBeenCalled()); }); it("opens CalendarSearch modal when Other Add button is clicked", () => { @@ -131,10 +198,10 @@ describe("CalendarSelection", () => { } ); - const addButtons = screen.getAllByRole("button"); + const addButtons = screen.getAllByTestId("AddIcon"); fireEvent.click(addButtons[1]); // seccond Add button (other) - expect(screen.getByRole("presentation")).toBeInTheDocument(); + expect(screen.getByText("Browse other calendars")).toBeInTheDocument(); }); it("renders only personal calendars if no delegated/other exist", () => { diff --git a/__test__/features/Calendars/CalendarAPI.test.tsx b/__test__/features/Calendars/CalendarAPI.test.tsx index c72a2c8..2c584a2 100644 --- a/__test__/features/Calendars/CalendarAPI.test.tsx +++ b/__test__/features/Calendars/CalendarAPI.test.tsx @@ -5,6 +5,7 @@ import { getCalendars, postCalendar, proppatchCalendar, + removeCalendar, } from "../../../src/features/Calendars/CalendarApi"; import { clientConfig } from "../../../src/features/User/oidcAuth"; import { api } from "../../../src/utils/apiUtils"; @@ -99,4 +100,16 @@ describe("Calendar API", () => { }), }); }); + + it("remove Calendar", async () => { + const calLink = "/calendars/calId.json"; + const result = await removeCalendar(calLink); + + expect(api).toHaveBeenCalledWith(`dav${calLink}`, { + method: "DELETE", + headers: { + Accept: "application/json, text/plain, */*", + }, + }); + }); }); diff --git a/__test__/features/Calendars/CalendarModal.test.tsx b/__test__/features/Calendars/CalendarModal.test.tsx index 0fcdfb9..f78fbef 100644 --- a/__test__/features/Calendars/CalendarModal.test.tsx +++ b/__test__/features/Calendars/CalendarModal.test.tsx @@ -1,5 +1,5 @@ import { screen, fireEvent, waitFor } from "@testing-library/react"; -import CalendarPopover from "../../../src/features/Calendars/CalendarModal"; +import CalendarPopover from "../../../src/components/Calendar/CalendarModal"; import { renderWithProviders } from "../../utils/Renderwithproviders"; import * as eventThunks from "../../../src/features/Calendars/CalendarSlice"; @@ -23,11 +23,7 @@ describe("CalendarPopover", () => { calendars: { list: {}, pending: true }, }; renderWithProviders( - , + , preloadedState ); }; @@ -133,7 +129,6 @@ describe("CalendarPopover (editing mode)", () => { it("prefills fields when calendar prop is given", () => { renderWithProviders( { }); test("Save button is disabled when name is empty or whitespace only", () => { - renderWithProviders( - , - { user: baseUser } - ); + renderWithProviders(, { + user: baseUser, + }); const saveButton = screen.getByRole("button", { name: /save/i }); expect(saveButton).toBeDisabled(); @@ -180,7 +170,6 @@ describe("CalendarPopover (editing mode)", () => { renderWithProviders( ([]); + const [prevRangeKey, setPrevRangeKey] = useState(""); + useEffect(() => { updateCalsDetails( Object.keys(tempcalendars), + prevTempCalendars, pending, - tempcalendars, rangeKey, + prevRangeKey, dispatch, calendarRange, "temp" ); - }, [rangeKey, Object.keys(tempcalendars).join(",")]); + + setPrevTempCalendars(Object.keys(tempcalendars)); + setPrevRangeKey(rangeKey); + }, [rangeKey, Object.keys(tempcalendars).join(","), pending]); const [anchorEl, setAnchorEl] = useState(null); const [anchorPosition, setAnchorPosition] = useState<{ diff --git a/src/features/Calendars/CalendarModal.tsx b/src/components/Calendar/CalendarModal.tsx similarity index 70% rename from src/features/Calendars/CalendarModal.tsx rename to src/components/Calendar/CalendarModal.tsx index 0accba4..6dd4fbb 100644 --- a/src/features/Calendars/CalendarModal.tsx +++ b/src/components/Calendar/CalendarModal.tsx @@ -2,26 +2,24 @@ import { useEffect, useState } from "react"; import { createCalendarAsync /*, updateCalendarAsync */, patchCalendarAsync, -} from "./CalendarSlice"; +} from "../../features/Calendars/CalendarSlice"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { - Popover, TextField, Button, - Box, - Typography, - ButtonGroup, + Dialog, + DialogTitle, + DialogContent, + DialogActions, } from "@mui/material"; -import { ColorPicker } from "../../components/Calendar/CalendarColorPicker"; -import { Calendars } from "./CalendarTypes"; +import { ColorPicker } from "./CalendarColorPicker"; +import { Calendars } from "../../features/Calendars/CalendarTypes"; function CalendarPopover({ - anchorEl, open, onClose, calendar, }: { - anchorEl: HTMLElement | null; open: boolean; onClose: ( event: object | null, @@ -96,21 +94,11 @@ function CalendarPopover({ ]; return ( - onClose(e, reason)} - anchorOrigin={{ vertical: "center", horizontal: "center" }} - transformOrigin={{ vertical: "center", horizontal: "center" }} - > - - - Calendar configuration - + onClose(e, reason)}> + + Calendar configuration + + setColor(color)} selectedColor={color} /> + - - - - - - + + + + + ); } diff --git a/src/components/Calendar/CalendarSelection.tsx b/src/components/Calendar/CalendarSelection.tsx index c7fa080..7a43327 100644 --- a/src/components/Calendar/CalendarSelection.tsx +++ b/src/components/Calendar/CalendarSelection.tsx @@ -2,15 +2,19 @@ import Accordion from "@mui/material/Accordion"; import AccordionDetails from "@mui/material/AccordionDetails"; import AccordionSummary from "@mui/material/AccordionSummary"; import Typography from "@mui/material/Typography"; -import { useAppSelector } from "../../app/hooks"; +import { useAppDispatch, useAppSelector } from "../../app/hooks"; import AddIcon from "@mui/icons-material/Add"; import { useState } from "react"; -import CalendarPopover from "../../features/Calendars/CalendarModal"; +import CalendarPopover from "./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 ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import CalendarSearch from "./CalendarSearch"; +import { Divider, Menu, MenuItem } from "@mui/material"; +import { removeCalendarAsync } from "../../features/Calendars/CalendarSlice"; +import { DeleteCalendarDialog } from "./DeleteCalendarDialog"; function CalendarAccordion({ title, @@ -59,21 +63,21 @@ function CalendarAccordion({ )} - {calendars.map((id) => - CalendarSelector( - allCalendars, - id, - selectedCalendars, - handleToggle, - () => setOpen(id) - ) - )} + {calendars.map((id) => ( + setOpen(id)} + /> + ))} ); } -import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; -import CalendarSearch from "./CalendarSearch"; export default function CalendarSelection({ selectedCalendars, @@ -151,7 +155,6 @@ export default function CalendarSelection({ /> { @@ -173,30 +176,79 @@ export default function CalendarSelection({ ); } -function CalendarSelector( - calendars: Record, - id: string, - selectedCalendars: string[], - handleCalendarToggle: (name: string) => void, - setOpen: Function -) { +function CalendarSelector({ + calendars, + id, + isPersonnal, + selectedCalendars, + handleCalendarToggle, + setOpen, +}: { + calendars: Record; + id: string; + isPersonnal: boolean; + selectedCalendars: string[]; + handleCalendarToggle: (name: string) => void; + setOpen: Function; +}) { + const dispatch = useAppDispatch(); + const calLink = + useAppSelector((state) => state.calendars.list[id].link) ?? ""; + + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + const [userId, calId] = id.split("/"); + const isDefault = isPersonnal && userId === calId; + + const [deletePopupOpen, setDeletePopupOpen] = useState(false); + const handleDeleteConfirm = () => { + dispatch(removeCalendarAsync({ calId: id, calLink })); + setDeletePopupOpen(false); + handleClose(); + }; return ( -
- - setOpen()}> - - -
+ <> +
+ + + + +
+ + setOpen()}>Modify + {!isDefault && } + {!isDefault && ( + setDeletePopupOpen(!deletePopupOpen)}> + {isPersonnal ? "Delete" : "Remove"} + + )} + + + + ); } diff --git a/src/components/Calendar/DeleteCalendarDialog.tsx b/src/components/Calendar/DeleteCalendarDialog.tsx new file mode 100644 index 0000000..bfe5cd4 --- /dev/null +++ b/src/components/Calendar/DeleteCalendarDialog.tsx @@ -0,0 +1,43 @@ +import Dialog from "@mui/material/Dialog"; +import { Calendars } from "../../features/Calendars/CalendarTypes"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogActions from "@mui/material/DialogActions"; +import Button from "@mui/material/Button"; + +export function DeleteCalendarDialog({ + deletePopupOpen, + setDeletePopupOpen, + calendars, + id, + isPersonnal, + handleDeleteConfirm, +}: { + deletePopupOpen: boolean; + setDeletePopupOpen: (e: boolean) => void; + calendars: Record; + id: string; + isPersonnal: boolean; + handleDeleteConfirm: () => void; +}) { + return ( + setDeletePopupOpen(false)}> + Remove {calendars[id].name}? + + + Are you sure you want to remove this calendar?{" "} + {isPersonnal + ? "You will loose all events in this calendar." + : "You will loose access to its events. You will still be able to add it back later."} + + + + + + + + ); +} diff --git a/src/components/Calendar/utils/calendarUtils.ts b/src/components/Calendar/utils/calendarUtils.ts index bbc5752..4852fb4 100644 --- a/src/components/Calendar/utils/calendarUtils.ts +++ b/src/components/Calendar/utils/calendarUtils.ts @@ -66,18 +66,35 @@ export const extractEvents = ( export const updateCalsDetails = ( selectedCalendars: string[], + previousSelectedCalendars: string[], pending: boolean, - calendars: Record, rangeKey: string, + previousRangeKey: string, dispatch: Function, calendarRange: { start: Date; end: Date }, calType?: "temp" ) => { - selectedCalendars.forEach((id) => { - if (Object.keys(calendars[id].events).length > 0) { - return; - } - if (!pending && rangeKey) { + if (pending || !rangeKey) return; + + const newCalendars = selectedCalendars.filter( + (id) => !previousSelectedCalendars.includes(id) + ); + + newCalendars.forEach((id) => { + dispatch( + getCalendarDetailAsync({ + calId: id, + match: { + start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start), + end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end), + }, + calType, + }) + ); + }); + + if (rangeKey !== previousRangeKey) { + selectedCalendars.forEach((id) => { dispatch( getCalendarDetailAsync({ calId: id, @@ -88,6 +105,6 @@ export const updateCalsDetails = ( calType, }) ); - } - }); + }); + } }; diff --git a/src/features/Calendars/CalendarApi.ts b/src/features/Calendars/CalendarApi.ts index 8509879..cb7be1e 100644 --- a/src/features/Calendars/CalendarApi.ts +++ b/src/features/Calendars/CalendarApi.ts @@ -102,3 +102,13 @@ export async function proppatchCalendar( }); return response; } + +export async function removeCalendar(calLink: string) { + const response = await api(`dav${calLink}`, { + method: "DELETE", + headers: { + Accept: "application/json, text/plain, */*", + }, + }); + return response; +} diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index cabeed7..0f10074 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -7,6 +7,7 @@ import { getCalendars, postCalendar, proppatchCalendar, + removeCalendar, } from "./CalendarApi"; import { getOpenPaasUser, getUserDetails } from "../User/userAPI"; import { parseCalendarEvent } from "../Events/eventUtils"; @@ -191,6 +192,22 @@ export const patchCalendarAsync = createAsyncThunk< }; }); +export const removeCalendarAsync = createAsyncThunk< + { + calId: string; + }, + { + calId: string; + calLink: string; + } +>("calendars/removeCalendar", async ({ calId, calLink }) => { + const response = await removeCalendar(calLink); + return { + calId, + calLink, + }; +}); + export const moveEventAsync = createAsyncThunk< { calId: string; events: CalendarEvent[] }, // Return type { cal: Calendars; newEvent: CalendarEvent; newURL: string } // Arg type @@ -258,6 +275,7 @@ export const addSharedCalendarAsync = createAsyncThunk< { calId: string; color: string; + link: string; name: string; desc: string; owner: string; @@ -278,6 +296,7 @@ export const addSharedCalendarAsync = createAsyncThunk< .replace("/calendars/", "") .replace(".json", ""), color: cal.cal["apple:color"], + link: `/calendars/${userId}/${calId}.json`, desc: cal.cal["caldav:description"], name: cal.cal["dav:name"], owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ @@ -527,6 +546,7 @@ const CalendarSlice = createSlice({ state.list[action.payload.calId] = { color: action.payload.color, id: action.payload.calId, + link: action.payload.link, description: action.payload.desc, name: action.payload.name, events: {}, @@ -534,6 +554,10 @@ const CalendarSlice = createSlice({ ownerEmails: action.payload.ownerEmails, } as Calendars; }) + .addCase(removeCalendarAsync.fulfilled, (state, action) => { + state.pending = false; + delete state.list[action.payload.calId]; + }) .addCase(getCalendarDetailAsync.pending, (state) => { state.pending = true; }) @@ -563,6 +587,9 @@ const CalendarSlice = createSlice({ }) .addCase(addSharedCalendarAsync.pending, (state) => { state.pending = true; + }) + .addCase(removeCalendarAsync.pending, (state) => { + state.pending = true; }); }, });