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 <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2025-09-30 10:27:47 +02:00
committed by GitHub
parent b46be3af29
commit 475ab79731
10 changed files with 324 additions and 111 deletions
+73 -6
View File
@@ -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(
<CalendarSelection
@@ -101,7 +106,7 @@ describe("CalendarSelection", () => {
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(
<CalendarSelection
selectedCalendars={[]}
@@ -114,9 +119,71 @@ describe("CalendarSelection", () => {
);
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(
<CalendarSelection
selectedCalendars={[]}
setSelectedCalendars={jest.fn()}
/>,
{
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(
<CalendarSelection
selectedCalendars={[]}
setSelectedCalendars={jest.fn()}
/>,
{
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", () => {
@@ -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, */*",
},
});
});
});
@@ -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(
<CalendarPopover
anchorEl={document.body}
open={open}
onClose={mockOnClose}
/>,
<CalendarPopover open={open} onClose={mockOnClose} />,
preloadedState
);
};
@@ -133,7 +129,6 @@ describe("CalendarPopover (editing mode)", () => {
it("prefills fields when calendar prop is given", () => {
renderWithProviders(
<CalendarPopover
anchorEl={document.body}
open={true}
onClose={mockOnClose}
calendar={existingCalendar}
@@ -149,14 +144,9 @@ describe("CalendarPopover (editing mode)", () => {
});
test("Save button is disabled when name is empty or whitespace only", () => {
renderWithProviders(
<CalendarPopover
anchorEl={document.createElement("div")}
open={true}
onClose={jest.fn()}
/>,
{ user: baseUser }
);
renderWithProviders(<CalendarPopover open={true} onClose={jest.fn()} />, {
user: baseUser,
});
const saveButton = screen.getByRole("button", { name: /save/i });
expect(saveButton).toBeDisabled();
@@ -180,7 +170,6 @@ describe("CalendarPopover (editing mode)", () => {
renderWithProviders(
<CalendarPopover
anchorEl={document.body}
open={true}
onClose={mockOnClose}
calendar={existingCalendar}
+9 -2
View File
@@ -153,17 +153,24 @@ export default function CalendarApp({
calendarRange.end,
]);
const [prevTempCalendars, setPrevTempCalendars] = useState<string[]>([]);
const [prevRangeKey, setPrevRangeKey] = useState<string>("");
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<HTMLElement | null>(null);
const [anchorPosition, setAnchorPosition] = useState<{
@@ -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 (
<Popover
open={open}
anchorEl={anchorEl}
onClose={(e, reason) => onClose(e, reason)}
anchorOrigin={{ vertical: "center", horizontal: "center" }}
transformOrigin={{ vertical: "center", horizontal: "center" }}
>
<Box p={2}>
<Typography
variant="h6"
gutterBottom
style={{ backgroundColor: color }}
>
Calendar configuration
</Typography>
<Dialog open={open} onClose={(e, reason) => onClose(e, reason)}>
<DialogTitle style={{ backgroundColor: color }}>
Calendar configuration
</DialogTitle>
<DialogContent>
<TextField
fullWidth
label="Name"
@@ -133,24 +121,24 @@ function CalendarPopover({
onChange={(color) => setColor(color)}
selectedColor={color}
/>
</DialogContent>
<Box mt={2} display="flex" justifyContent="flex-end" gap={1}>
<Button
variant="outlined"
onClick={(e) => onClose({}, "backdropClick")}
>
Cancel
</Button>
<Button
disabled={!name.trim()}
variant="contained"
onClick={handleSave}
>
Save
</Button>
</Box>
</Box>
</Popover>
<DialogActions>
<Button
variant="outlined"
onClick={(e) => onClose({}, "backdropClick")}
>
Cancel
</Button>
<Button
disabled={!name.trim()}
variant="contained"
onClick={handleSave}
>
Save
</Button>
</DialogActions>
</Dialog>
);
}
+90 -38
View File
@@ -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({
)}
</AccordionSummary>
<AccordionDetails>
{calendars.map((id) =>
CalendarSelector(
allCalendars,
id,
selectedCalendars,
handleToggle,
() => setOpen(id)
)
)}
{calendars.map((id) => (
<CalendarSelector
key={id}
calendars={allCalendars}
id={id}
isPersonnal={defaultExpanded}
selectedCalendars={selectedCalendars}
handleCalendarToggle={handleToggle}
setOpen={() => setOpen(id)}
/>
))}
</AccordionDetails>
</Accordion>
);
}
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import CalendarSearch from "./CalendarSearch";
export default function CalendarSelection({
selectedCalendars,
@@ -151,7 +155,6 @@ export default function CalendarSelection({
/>
</div>
<CalendarPopover
anchorEl={anchorElCal}
open={Boolean(anchorElCal)}
calendar={calendars[selectedCalId] ?? undefined}
onClose={() => {
@@ -173,30 +176,79 @@ export default function CalendarSelection({
);
}
function CalendarSelector(
calendars: Record<string, Calendars>,
id: string,
selectedCalendars: string[],
handleCalendarToggle: (name: string) => void,
setOpen: Function
) {
function CalendarSelector({
calendars,
id,
isPersonnal,
selectedCalendars,
handleCalendarToggle,
setOpen,
}: {
calendars: Record<string, Calendars>;
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 | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
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 (
<div key={id}>
<label>
<Checkbox
sx={{
color: calendars[id].color,
"&.Mui-checked": { color: calendars[id].color },
}}
size="small"
checked={selectedCalendars.includes(id)}
onChange={() => handleCalendarToggle(id)}
/>
{calendars[id].name}
</label>
<IconButton onClick={() => setOpen()}>
<MoreVertIcon />
</IconButton>
</div>
<>
<div>
<label>
<Checkbox
sx={{
color: calendars[id].color,
"&.Mui-checked": { color: calendars[id].color },
}}
size="small"
checked={selectedCalendars.includes(id)}
onChange={() => handleCalendarToggle(id)}
/>
{calendars[id].name}
</label>
<IconButton onClick={handleClick}>
<MoreVertIcon />
</IconButton>
</div>
<Menu id={id} anchorEl={anchorEl} open={open} onClose={handleClose}>
<MenuItem onClick={() => setOpen()}>Modify</MenuItem>
{!isDefault && <Divider />}
{!isDefault && (
<MenuItem onClick={() => setDeletePopupOpen(!deletePopupOpen)}>
{isPersonnal ? "Delete" : "Remove"}
</MenuItem>
)}
</Menu>
<DeleteCalendarDialog
deletePopupOpen={deletePopupOpen}
setDeletePopupOpen={setDeletePopupOpen}
calendars={calendars}
id={id}
isPersonnal={isPersonnal}
handleDeleteConfirm={handleDeleteConfirm}
/>
</>
);
}
@@ -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<string, Calendars>;
id: string;
isPersonnal: boolean;
handleDeleteConfirm: () => void;
}) {
return (
<Dialog open={deletePopupOpen} onClose={() => setDeletePopupOpen(false)}>
<DialogTitle>Remove {calendars[id].name}?</DialogTitle>
<DialogContent>
<DialogContentText>
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."}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeletePopupOpen(false)}>Cancel</Button>
<Button onClick={handleDeleteConfirm} variant="contained">
{isPersonnal ? "Delete" : "Remove"}
</Button>
</DialogActions>
</Dialog>
);
}
+25 -8
View File
@@ -66,18 +66,35 @@ export const extractEvents = (
export const updateCalsDetails = (
selectedCalendars: string[],
previousSelectedCalendars: string[],
pending: boolean,
calendars: Record<string, Calendars>,
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,
})
);
}
});
});
}
};
+10
View File
@@ -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;
}
+27
View File
@@ -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;
});
},
});