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 <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2025-09-17 15:05:27 +02:00
committed by GitHub
parent 5522673342
commit f640cac8ac
8 changed files with 358 additions and 85 deletions
@@ -113,7 +113,7 @@ describe("CalendarSelection", () => {
} }
); );
const addButton = screen.getByRole("button"); const addButton = screen.getByTestId("AddIcon");
fireEvent.click(addButton); fireEvent.click(addButton);
expect(screen.getByRole("presentation")).toBeInTheDocument(); expect(screen.getByRole("presentation")).toBeInTheDocument();
@@ -4,6 +4,7 @@ import {
getCalendar, getCalendar,
getCalendars, getCalendars,
postCalendar, postCalendar,
proppatchCalendar,
} from "../../../src/features/Calendars/CalendarApi"; } from "../../../src/features/Calendars/CalendarApi";
import { clientConfig } from "../../../src/features/User/oidcAuth"; import { clientConfig } from "../../../src/features/User/oidcAuth";
import { api } from "../../../src/utils/apiUtils"; 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",
}),
});
});
}); });
@@ -1,6 +1,4 @@
import { screen, fireEvent } from "@testing-library/react"; import { screen, fireEvent, waitFor } from "@testing-library/react";
import { useAppDispatch } from "../../../src/app/hooks";
import { createCalendar } from "../../../src/features/Calendars/CalendarSlice";
import CalendarPopover from "../../../src/features/Calendars/CalendarModal"; import CalendarPopover from "../../../src/features/Calendars/CalendarModal";
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from "../../utils/Renderwithproviders";
import * as eventThunks from "../../../src/features/Calendars/CalendarSlice"; import * as eventThunks from "../../../src/features/Calendars/CalendarSlice";
@@ -97,10 +95,6 @@ describe("CalendarPopover", () => {
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); 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", () => { it("calls onClose when Cancel clicked", () => {
@@ -111,3 +105,113 @@ describe("CalendarPopover", () => {
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick"); 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(
<CalendarPopover
anchorEl={document.body}
open={true}
onClose={mockOnClose}
calendar={existingCalendar}
/>,
{ 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(
<CalendarPopover
anchorEl={document.createElement("div")}
open={true}
onClose={jest.fn()}
/>,
{ 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(
<CalendarPopover
anchorEl={document.body}
open={true}
onClose={mockOnClose}
calendar={existingCalendar}
/>,
{ 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();
});
});
+74 -44
View File
@@ -1,8 +1,12 @@
import { Button } from "@mui/material";
import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { useAppDispatch, useAppSelector } from "../../app/hooks";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import CalendarPopover from "../../features/Calendars/CalendarModal"; 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({ export default function CalendarSelection({
selectedCalendars, selectedCalendars,
@@ -27,9 +31,9 @@ export default function CalendarSelection({
prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name] prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name]
); );
}; };
const [selectedCalId, setSelectedCalId] = useState("");
const [anchorElCal, setAnchorElCal] = useState<HTMLElement | null>(null); const [anchorElCal, setAnchorElCal] = useState<HTMLElement | null>(null);
return ( return (
<> <>
<div> <div>
@@ -39,39 +43,35 @@ export default function CalendarSelection({
<AddIcon /> <AddIcon />
</Button> </Button>
</div> </div>
{personnalCalendars.map((id) => { {personnalCalendars.map((id) =>
return ( CalendarSelector(
<div key={id}> calendars,
<label> id,
<input selectedCalendars,
type="checkbox" handleCalendarToggle,
style={{ backgroundColor: calendars[id].color }} () => {
checked={selectedCalendars.includes(id)} setAnchorElCal(document.body);
onChange={() => handleCalendarToggle(id)} setSelectedCalId(id);
/> }
{calendars[id].name} )
</label> )}
</div>
);
})}
{delegatedCalendars.length > 0 && ( {delegatedCalendars.length > 0 && (
<> <>
<span className="calendarListHeader"> <span className="calendarListHeader">
<h3>Delegated Calendars</h3> <h3>Delegated Calendars</h3>
</span> </span>
{delegatedCalendars.map((id) => ( {delegatedCalendars.map((id) =>
<div key={id}> CalendarSelector(
<label> calendars,
<input id,
type="checkbox" selectedCalendars,
style={{ backgroundColor: calendars[id].color }} handleCalendarToggle,
checked={selectedCalendars.includes(id)} () => {
onChange={() => handleCalendarToggle(id)} setAnchorElCal(document.body);
/> setSelectedCalId(id);
{calendars[id].name} }
</label> )
</div> )}
))}
</> </>
)} )}
{sharedCalendars.length > 0 && ( {sharedCalendars.length > 0 && (
@@ -79,27 +79,57 @@ export default function CalendarSelection({
<span className="calendarListHeader"> <span className="calendarListHeader">
<h3>Shared Calendars</h3> <h3>Shared Calendars</h3>
</span> </span>
{sharedCalendars.map((id) => ( {sharedCalendars.map((id) =>
<div key={id}> CalendarSelector(
<label> calendars,
<input id,
type="checkbox" selectedCalendars,
style={{ backgroundColor: calendars[id].color }} handleCalendarToggle,
checked={selectedCalendars.includes(id)} () => {
onChange={() => handleCalendarToggle(id)} setAnchorElCal(document.body);
/> setSelectedCalId(id);
{calendars[id].name} }
</label> )
</div> )}
))}
</> </>
)} )}
</div> </div>
<CalendarPopover <CalendarPopover
anchorEl={anchorElCal} anchorEl={anchorElCal}
open={Boolean(anchorElCal)} open={Boolean(anchorElCal)}
onClose={() => setAnchorElCal(null)} onClose={() => {
setAnchorElCal(null);
}}
calendar={calendars[selectedCalId] ?? undefined}
/> />
</> </>
); );
} }
function CalendarSelector(
calendars: Record<string, Calendars>,
id: string,
selectedCalendars: string[],
handleCalendarToggle: (name: string) => void,
setOpen: Function
) {
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>
);
}
+24
View File
@@ -51,3 +51,27 @@ export async function postCalendar(
}); });
return response; return response;
} }
export async function proppatchCalendar(
calLink: string,
patch: { name: string; desc: string; color: string }
) {
const body: Record<string, string> = {};
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;
}
+68 -31
View File
@@ -1,5 +1,8 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { createCalendar, createCalendarAsync } from "./CalendarSlice"; import {
createCalendarAsync /*, updateCalendarAsync */,
patchCalendarAsync,
} from "./CalendarSlice";
import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { import {
Popover, Popover,
@@ -7,38 +10,76 @@ import {
Button, Button,
Box, Box,
Typography, Typography,
Select,
ButtonGroup, ButtonGroup,
} from "@mui/material"; } from "@mui/material";
import { Calendars } from "./CalendarTypes";
function CalendarPopover({ function CalendarPopover({
anchorEl, anchorEl,
open, open,
onClose, onClose,
calendar,
}: { }: {
anchorEl: HTMLElement | null; anchorEl: HTMLElement | null;
open: boolean; open: boolean;
onClose: (Calendar: {}, reason: "backdropClick" | "escapeKeyDown") => void; onClose: (
event: object | null,
reason: "backdropClick" | "escapeKeyDown"
) => void;
calendar?: Calendars;
}) { }) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const userId = const userId =
useAppSelector((state) => state.user.userData.openpaasId) ?? ""; useAppSelector((state) => state.user.userData.openpaasId) ?? "";
const [name, setName] = useState(""); const [name, setName] = useState(calendar?.name ?? "");
const [description, setDescription] = useState(""); const [description, setDescription] = useState(calendar?.description ?? "");
const [color, setColor] = useState(""); const [color, setColor] = useState(calendar?.color ?? "");
const handleSave = () => {
const calId = crypto.randomUUID();
if (name) {
dispatch(
createCalendarAsync({ name, desc: description, color, userId, calId })
);
onClose({}, "backdropClick");
// Reset useEffect(() => {
setName(""); if (open) {
setDescription(""); 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 = [ const palette = [
"#D50000", "#D50000",
"#E67C73", "#E67C73",
@@ -52,19 +93,14 @@ function CalendarPopover({
"#8E24AA", "#8E24AA",
"#616161", "#616161",
]; ];
return ( return (
<Popover <Popover
open={open} open={open}
anchorEl={anchorEl} anchorEl={anchorEl}
onClose={onClose} onClose={(e, reason) => onClose(e, reason)}
anchorOrigin={{ anchorOrigin={{ vertical: "center", horizontal: "center" }}
vertical: "center", transformOrigin={{ vertical: "center", horizontal: "center" }}
horizontal: "center",
}}
transformOrigin={{
vertical: "center",
horizontal: "center",
}}
> >
<Box p={2}> <Box p={2}>
<Typography <Typography
@@ -93,22 +129,23 @@ function CalendarPopover({
rows={2} rows={2}
/> />
<ButtonGroup> <ButtonGroup>
{palette.map((color) => ( {palette.map((c) => (
<Button <Button
style={{ backgroundColor: color }} key={c}
onClick={() => setColor(color)} style={{ backgroundColor: c }}
onClick={() => setColor(c)}
/> />
))} ))}
</ButtonGroup> </ButtonGroup>
<Box mt={2} display="flex" justifyContent="flex-end" gap={1}> <Box mt={2} display="flex" justifyContent="flex-end" gap={1}>
<Button <Button
variant="outlined" variant="outlined"
onClick={() => onClose({}, "backdropClick")} onClick={(e) => onClose({}, "backdropClick")}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
disabled={name && name !== "" ? false : true} disabled={!name.trim()}
variant="contained" variant="contained"
onClick={handleSave} onClick={handleSave}
> >
+57 -2
View File
@@ -1,7 +1,12 @@
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"; import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Calendars } from "./CalendarTypes"; import { Calendars } from "./CalendarTypes";
import { CalendarEvent } from "../Events/EventsTypes"; import { CalendarEvent } from "../Events/EventsTypes";
import { getCalendar, getCalendars, postCalendar } from "./CalendarApi"; import {
getCalendar,
getCalendars,
postCalendar,
proppatchCalendar,
} from "./CalendarApi";
import { getOpenPaasUser, getUserDetails } from "../User/userAPI"; import { getOpenPaasUser, getUserDetails } from "../User/userAPI";
import { parseCalendarEvent } from "../Events/eventUtils"; import { parseCalendarEvent } from "../Events/eventUtils";
import { deleteEvent, getEvent, moveEvent, putEvent } from "../Events/EventApi"; import { deleteEvent, getEvent, moveEvent, putEvent } from "../Events/EventApi";
@@ -17,11 +22,12 @@ export const getCalendarsListAsync = createAsyncThunk<
for (const cal of rawCalendars) { for (const cal of rawCalendars) {
const name = cal["dav:name"]; const name = cal["dav:name"];
const description = cal["dav:description"]; const description = cal["caldav:description"];
let delegated = false; let delegated = false;
let source = cal["calendarserver:source"] let source = cal["calendarserver:source"]
? cal["calendarserver:source"]._links.self.href ? cal["calendarserver:source"]._links.self.href
: cal._links.self.href; : cal._links.self.href;
const link = cal._links.self.href;
if (cal["calendarserver:delegatedsource"]) { if (cal["calendarserver:delegatedsource"]) {
source = cal["calendarserver:delegatedsource"]; source = cal["calendarserver:delegatedsource"];
delegated = true; delegated = true;
@@ -33,6 +39,7 @@ export const getCalendarsListAsync = createAsyncThunk<
importedCalendars[id] = { importedCalendars[id] = {
id, id,
name, name,
link,
owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${
ownerData.lastname ownerData.lastname
}`, }`,
@@ -121,6 +128,25 @@ export const getEventAsync = createAsyncThunk<
event: response, event: response,
}; };
}); });
export const patchCalendarAsync = createAsyncThunk<
{
calId: string;
calLink: string;
patch: { name: string; desc: string; color: string };
}, // Return type
{
calId: string;
calLink: string;
patch: { name: string; desc: string; color: string };
} // Arg type
>("calendars/patchCalendar", async ({ calId, calLink, patch }) => {
const response = await proppatchCalendar(calLink, patch);
return {
calId,
calLink,
patch,
};
});
export const moveEventAsync = createAsyncThunk< export const moveEventAsync = createAsyncThunk<
{ calId: string; events: CalendarEvent[] }, // Return type { calId: string; events: CalendarEvent[] }, // Return type
@@ -339,6 +365,32 @@ const CalendarSlice = createSlice({
name: action.payload.name, name: action.payload.name,
} as unknown as Calendars; } as unknown as Calendars;
}) })
.addCase(patchCalendarAsync.fulfilled, (state, action) => {
state.pending = false;
if (action.payload.patch.color) {
state.list[action.payload.calId] = {
...state.list[action.payload.calId],
color: action.payload.patch.color,
};
Object.keys(state.list[action.payload.calId].events).forEach(
(evId) =>
(state.list[action.payload.calId].events[evId].color =
action.payload.patch.color)
);
}
if (action.payload.patch.desc) {
state.list[action.payload.calId] = {
...state.list[action.payload.calId],
description: action.payload.patch.desc,
};
}
if (action.payload.patch.name) {
state.list[action.payload.calId] = {
...state.list[action.payload.calId],
name: action.payload.patch.name,
};
}
})
.addCase(getCalendarDetailAsync.pending, (state) => { .addCase(getCalendarDetailAsync.pending, (state) => {
state.pending = true; state.pending = true;
}) })
@@ -357,6 +409,9 @@ const CalendarSlice = createSlice({
.addCase(deleteEventAsync.pending, (state) => { .addCase(deleteEventAsync.pending, (state) => {
state.pending = true; state.pending = true;
}) })
.addCase(patchCalendarAsync.pending, (state) => {
state.pending = true;
})
.addCase(createCalendarAsync.pending, (state) => { .addCase(createCalendarAsync.pending, (state) => {
state.pending = true; state.pending = true;
}); });
+1
View File
@@ -2,6 +2,7 @@ import { CalendarEvent } from "../Events/EventsTypes";
export interface Calendars { export interface Calendars {
id: string; id: string;
link: string;
name: string; name: string;
delegated?: boolean; delegated?: boolean;
prodid?: string; prodid?: string;