ability to register a shared calendar (#118)

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2025-09-22 20:45:02 +02:00
committed by GitHub
parent 8122772bec
commit cd724a30a0
12 changed files with 721 additions and 136 deletions
+51 -11
View File
@@ -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 "@testing-library/jest-dom";
import CalendarSelection from "../../src/components/Calendar/CalendarSelection"; import CalendarSelection from "../../src/components/Calendar/CalendarSelection";
import { renderWithProviders } from "../utils/Renderwithproviders"; import { renderWithProviders } from "../utils/Renderwithproviders";
@@ -16,7 +16,7 @@ describe("CalendarSelection", () => {
const calendarsMock = { const calendarsMock = {
"user1/cal1": { "user1/cal1": {
name: "Calendar personnal", name: "Calendar personal",
id: "user1/cal1", id: "user1/cal1",
color: "#FF0000", color: "#FF0000",
ownerEmails: ["alice@example.com"], 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( renderWithProviders(
<CalendarSelection <CalendarSelection
selectedCalendars={["user1/cal1"]} selectedCalendars={["user1/cal1"]}
@@ -52,7 +52,7 @@ describe("CalendarSelection", () => {
expect(screen.getByText("Delegated Calendars")).toBeInTheDocument(); expect(screen.getByText("Delegated Calendars")).toBeInTheDocument();
expect(screen.getByText("Other 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 delegated")).not.toBeChecked();
expect(screen.getByLabelText("Calendar shared")).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); fireEvent.click(checkbox);
expect(setSelectedCalendars).toHaveBeenCalledWith(expect.any(Function)); 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); fireEvent.click(checkbox);
const updater = setSelectedCalendars.mock.calls[0][0]; const updater = setSelectedCalendars.mock.calls[0][0];
expect(updater(["user1/cal1"])).toEqual([]); expect(updater(["user1/cal1"])).toEqual([]);
}); });
it("opens CalendarPopover when Add button is clicked", () => { it("opens CalendarPopover modal when personal Add button is clicked", () => {
renderWithProviders( renderWithProviders(
<CalendarSelection <CalendarSelection
selectedCalendars={[]} selectedCalendars={[]}
@@ -113,13 +113,31 @@ describe("CalendarSelection", () => {
} }
); );
const addButton = screen.getAllByTestId("AddIcon")[0]; const addButtons = screen.getAllByRole("button");
fireEvent.click(addButton); fireEvent.click(addButtons[0]);
waitFor(() => expect(screen.getByRole("presentation")).toBeInTheDocument());
});
it("opens CalendarSearch modal when Other Add button is clicked", () => {
renderWithProviders(
<CalendarSelection
selectedCalendars={[]}
setSelectedCalendars={jest.fn()}
/>,
{
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(); 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( renderWithProviders(
<CalendarSelection <CalendarSelection
selectedCalendars={[]} selectedCalendars={[]}
@@ -138,7 +156,7 @@ describe("CalendarSelection", () => {
expect(screen.getByText("Personnal Calendars")).toBeInTheDocument(); expect(screen.getByText("Personnal Calendars")).toBeInTheDocument();
expect(screen.queryByText("Delegated Calendars")).not.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", () => { it("renders nothing when no calendars are present", () => {
@@ -155,4 +173,26 @@ describe("CalendarSelection", () => {
expect(screen.queryByLabelText(/Calendar/)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/Calendar/)).not.toBeInTheDocument();
}); });
it("expands and collapses accordions when clicked", () => {
renderWithProviders(
<CalendarSelection
selectedCalendars={[]}
setSelectedCalendars={jest.fn()}
/>,
{
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");
});
}); });
@@ -56,15 +56,15 @@ describe("CalendarPopover", () => {
renderPopover(); renderPopover();
// There are multiple color buttons; pick the first // There are multiple color buttons; pick the first
const colorButtons = screen const colorButtons = screen.getAllByRole("button", {
.getAllByRole("button") name: /select color/i,
.filter((btn) => btn.style.backgroundColor !== ""); });
fireEvent.click(colorButtons[0]); fireEvent.click(colorButtons[0]);
// The header background should update (check via inline style) // The header background should update (check via inline style)
expect( expect(
screen.getByText("Calendar configuration").style.backgroundColor 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", () => { it("dispatches createCalendar and calls onClose when Save clicked", () => {
@@ -82,12 +82,9 @@ describe("CalendarPopover", () => {
target: { value: "Test Description" }, target: { value: "Test Description" },
}); });
const expectedColor = "#D50000"; const colorButtons = screen.getAllByRole("button", {
name: /select color/i,
const colorButtons = screen });
.getAllByRole("button")
.filter((btn) => btn.style.backgroundColor !== "");
fireEvent.click(colorButtons[0]); fireEvent.click(colorButtons[0]);
fireEvent.click(screen.getByText(/Save/i)); fireEvent.click(screen.getByText(/Save/i));
+37 -85
View File
@@ -1,21 +1,7 @@
import {
Autocomplete,
Avatar,
CircularProgress,
ListItem,
ListItemAvatar,
ListItemText,
TextField,
} from "@mui/material";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { searchUsers } from "../../features/User/userAPI"; import { searchUsers } from "../../features/User/userAPI";
import { userAttendee } from "../../features/User/userDataTypes"; import { userAttendee } from "../../features/User/userDataTypes";
import { PeopleSearch, User } from "./PeopleSearch";
interface User {
email: string;
displayName: string;
avatarUrl: string;
}
export default function UserSearch({ export default function UserSearch({
attendees, attendees,
@@ -26,77 +12,43 @@ export default function UserSearch({
setAttendees: Function; setAttendees: Function;
disabled?: boolean; disabled?: boolean;
}) { }) {
const [query, setQuery] = useState(""); const [selectedUsers, setSelectedUsers] = useState(
const [options, setOptions] = useState<User[]>([]); attendees.map((a) => ({
const [loading, setLoading] = useState(false); email: a.cal_address,
displayName: a.cn ?? "",
avatarUrl: "",
openpaasId: "",
}))
);
useEffect(() => { useEffect(() => {
const delayDebounceFn = setTimeout(async () => { setSelectedUsers(
if (query) { attendees.map((a) => ({
setLoading(true); email: a.cal_address,
const res = await searchUsers(query); displayName: a.cn ?? "",
setOptions(res); avatarUrl: "",
setLoading(false); openpaasId: "",
} }))
}, 300); );
}, [attendees]);
return () => clearTimeout(delayDebounceFn);
}, [query]);
return ( return (
<Autocomplete <>
multiple <PeopleSearch
options={options} selectedUsers={selectedUsers}
disabled={disabled} disabled={disabled}
loading={loading} onChange={(event: any, value: User[]) => {
filterOptions={(x) => x} setAttendees(
fullWidth value.map((a: User) => ({
getOptionLabel={(option) => option.displayName || option.email} cn: a.displayName,
filterSelectedOptions cal_address: a.email,
onInputChange={(event, value) => setQuery(value)} partstat: "NEED_ACTION",
onChange={(event, value) => rsvp: "FALSE",
setAttendees( role: "CHAIR",
value.map((a) => ({ cutype: "INDIVIDUAL",
cn: a.displayName, }))
cal_address: a.email, );
partstat: "NEED_ACTION", setSelectedUsers(value);
rsvp: "FALSE", }}
role: "CHAIR", />
cutype: "INDIVIDUAL", </>
}))
)
}
renderInput={(params) => (
<TextField
{...params}
label="Search user"
InputProps={{
...params.InputProps,
endAdornment: (
<>
{loading ? (
<CircularProgress color="inherit" size={20} />
) : null}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
renderOption={(props, option) => {
if (attendees.find((a) => a.cal_address === option.email)) return;
return (
<ListItem {...props} key={option.email} disableGutters>
<ListItemAvatar>
<Avatar src={option.avatarUrl} alt={option.displayName} />
</ListItemAvatar>
<ListItemText
primary={option.displayName}
secondary={option.email}
/>
</ListItem>
);
}}
/>
); );
} }
+106
View File
@@ -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<User[]>([]);
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 (
<Autocomplete
multiple
options={options}
disabled={disabled}
loading={loading}
filterOptions={(x) => x}
fullWidth
getOptionLabel={(option) => option.displayName || option.email}
filterSelectedOptions
value={selectedUsers}
onInputChange={(event, value) => setQuery(value)}
onChange={(event, value) => onChange(event, value)}
renderInput={(params) => (
<TextField
{...params}
label="Search user"
slotProps={{
input: {
...params.InputProps,
endAdornment: (
<>
{loading ? (
<CircularProgress color="inherit" size={20} />
) : null}
{params.InputProps.endAdornment}
</>
),
},
}}
/>
)}
renderOption={(props, option) => {
if (selectedUsers.find((u) => u.email === option.email)) return;
return (
<ListItem
{...props}
key={option.email + option.displayName}
disableGutters
>
<ListItemAvatar>
<Avatar src={option.avatarUrl} alt={option.displayName} />
</ListItemAvatar>
<ListItemText
primary={option.displayName}
secondary={option.email}
/>
</ListItem>
);
}}
/>
);
}
@@ -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 (
<Box display="flex" alignItems="center" gap={1}>
{colors.map((c) => (
<Box
key={c}
role="button"
aria-label={`select color ${c}`}
onClick={() => 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",
}}
/>
))}
</Box>
);
}
+336
View File
@@ -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<string, any>;
owner: User;
}
function CalendarItem({
cal,
onRemove,
onColorChange,
}: {
cal: CalendarWithOwner;
onRemove: () => void;
onColorChange: (color: string) => void;
}) {
return (
<Box
key={cal.owner.email + cal.cal["dav:name"]}
display="flex"
alignItems="center"
justifyContent="space-between"
sx={{
borderRadius: 2,
border: "1px solid #e5e7eb",
p: 1,
mb: 1,
}}
>
<Box display="flex" alignItems="center" gap={2}>
<Avatar
src={cal.owner.avatarUrl}
alt={cal.owner.email}
sx={{
border: `2px solid ${cal.cal["apple:color"] ?? "transparent"}`,
boxShadow: cal.cal["apple:color"]
? `0 0 0 2px ${cal.cal["apple:color"]}`
: "none",
}}
/>
<Box>
<Typography variant="body1">{cal.cal["dav:name"]}</Typography>
<Typography variant="body2" color="textSecondary">
{cal.owner.email}
</Typography>
</Box>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<ColorPicker
selectedColor={cal.cal["apple:color"]}
onChange={onColorChange}
/>
<IconButton size="small" onClick={onRemove}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
</Box>
);
}
function SelectedCalendarsList({
calendars,
selectedCal,
onRemove,
onColorChange,
}: {
calendars: Record<string, Calendars>;
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 (
<Box mt={2}>
<Typography variant="subtitle1" gutterBottom>
Name
</Typography>
{Object.values(groupedByOwner).map(
({ owner, visibleCals, alreadyExisting }) => (
<Box key={owner.email} mb={2}>
{visibleCals.length > 0 ? (
visibleCals.map((cal) =>
cal.cal ? (
<CalendarItem
key={cal.owner.email + cal.cal["dav:name"]}
cal={cal}
onRemove={() => onRemove(cal)}
onColorChange={(color) => onColorChange(cal, color)}
/>
) : (
<Typography color="textSecondary">
No publicly available calendars for {owner.displayName}
</Typography>
)
)
) : alreadyExisting ? (
<Typography color="textSecondary">
No more Calendar for {owner.displayName}
</Typography>
) : null}
</Box>
)
)}
</Box>
);
}
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<CalendarWithOwner[]>([]);
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
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 (
<Modal
open={open}
onClose={() => {
onClose({}, "backdropClick");
setSelectedCalendars([]);
setSelectedUsers([]);
}}
>
<Box
sx={{
position: "absolute",
left: "50%",
top: "20%",
transform: "translate( -50%)",
width: "50vw",
maxHeight: "80vh",
}}
>
<Card
sx={{
p: 2,
borderRadius: 3,
boxShadow: 6,
display: "flex",
flexDirection: "column",
maxHeight: "80vh",
}}
>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<IconButton
size="small"
onClick={() => onClose({}, "backdropClick")}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<CardHeader title="Browse other calendars" sx={{ pb: 0 }} />
<CardContent sx={{ flex: 1, overflow: "auto" }}>
<PeopleSearch
selectedUsers={selectedUsers}
onChange={async (event: any, value: User[]) => {
setSelectedUsers(value);
const cals = await Promise.all(
value.map(async (user: User) => {
const cals = (await getCalendars(
user.openpaasId,
"sharedPublic=true&withRights=true"
)) as Record<string, any>;
return cals._embedded?.["dav:calendar"]
? cals._embedded["dav:calendar"].map(
(cal: Record<string, any>) => ({ cal, owner: user })
)
: { cal: undefined, owner: user };
})
);
setSelectedCalendars(cals.flat());
}}
/>
<SelectedCalendarsList
calendars={calendars}
selectedCal={selectedCal}
onRemove={(cal) => {
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
)
)
}
/>
</CardContent>
<CardActions sx={{ justifyContent: "flex-end", gap: 1 }}>
<Button
variant="outlined"
onClick={() => onClose({}, "backdropClick")}
>
Cancel
</Button>
<Button variant="contained" onClick={handleSave}>
Add
</Button>
</CardActions>
</Card>
</Box>
</Modal>
);
}
+17 -3
View File
@@ -72,6 +72,8 @@ function CalendarAccordion({
</Accordion> </Accordion>
); );
} }
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import CalendarSearch from "./CalendarSearch";
export default function CalendarSelection({ export default function CalendarSelection({
selectedCalendars, selectedCalendars,
@@ -102,6 +104,9 @@ export default function CalendarSelection({
const [selectedCalId, setSelectedCalId] = useState(""); const [selectedCalId, setSelectedCalId] = useState("");
const [anchorElCal, setAnchorElCal] = useState<HTMLElement | null>(null); const [anchorElCal, setAnchorElCal] = useState<HTMLElement | null>(null);
const [anchorElCalOthers, setAnchorElCalOthers] =
useState<HTMLElement | null>(null);
return ( return (
<> <>
<div> <div>
@@ -137,7 +142,7 @@ export default function CalendarSelection({
selectedCalendars={selectedCalendars} selectedCalendars={selectedCalendars}
showAddButton showAddButton
onAddClick={() => { onAddClick={() => {
setAnchorElCal(document.body); setAnchorElCalOthers(document.body);
}} }}
handleToggle={handleCalendarToggle} handleToggle={handleCalendarToggle}
setOpen={(id: string) => { setOpen={(id: string) => {
@@ -146,15 +151,24 @@ export default function CalendarSelection({
}} }}
/> />
</div> </div>
<CalendarPopover <CalendarPopover
anchorEl={anchorElCal} anchorEl={anchorElCal}
open={Boolean(anchorElCal)} open={Boolean(anchorElCal)}
calendar={calendars[selectedCalId] ?? undefined}
onClose={() => { onClose={() => {
setSelectedCalId(""); setSelectedCalId("");
setAnchorElCal(null); setAnchorElCal(null);
}} }}
calendar={calendars[selectedCalId] ?? undefined} />
<CalendarSearch
anchorEl={anchorElCalOthers}
open={Boolean(anchorElCalOthers)}
onClose={(newCalIds?: string[]) => {
setAnchorElCalOthers(null);
if (newCalIds?.length) {
newCalIds.forEach((id) => handleCalendarToggle(id));
}
}}
/> />
</> </>
); );
+36 -9
View File
@@ -1,15 +1,15 @@
import { api } from "../../utils/apiUtils"; 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 const calendars = await api
.get( .get(`dav/calendars/${userId}.json?${scope}`, {
`dav/calendars/${userId}.json?personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&withRights=true`, headers: {
{ Accept: "application/calendar+json",
headers: { },
Accept: "application/calendar+json", })
},
}
)
.json(); .json();
return calendars; return calendars;
} }
@@ -52,6 +52,33 @@ export async function postCalendar(
return response; return response;
} }
export async function addSharedCalendar(
userId: string,
calId: string,
cal: Record<string, any>
) {
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( export async function proppatchCalendar(
calLink: string, calLink: string,
patch: { name: string; desc: string; color: string } patch: { name: string; desc: string; color: string }
+6 -9
View File
@@ -12,6 +12,7 @@ import {
Typography, Typography,
ButtonGroup, ButtonGroup,
} from "@mui/material"; } from "@mui/material";
import { ColorPicker } from "../../components/Calendar/CalendarColorPicker";
import { Calendars } from "./CalendarTypes"; import { Calendars } from "./CalendarTypes";
function CalendarPopover({ function CalendarPopover({
@@ -128,15 +129,11 @@ function CalendarPopover({
multiline multiline
rows={2} rows={2}
/> />
<ButtonGroup> <ColorPicker
{palette.map((c) => ( onChange={(color) => setColor(color)}
<Button selectedColor={color}
key={c} />
style={{ backgroundColor: c }}
onClick={() => setColor(c)}
/>
))}
</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"
+75 -3
View File
@@ -2,6 +2,7 @@ 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 { import {
addSharedCalendar,
getCalendar, getCalendar,
getCalendars, getCalendars,
postCalendar, postCalendar,
@@ -184,11 +185,64 @@ export const deleteEventAsync = createAsyncThunk<
}); });
export const createCalendarAsync = createAsyncThunk< export const createCalendarAsync = createAsyncThunk<
{ userId: string; calId: string; color: string; name: string; desc: string }, // Return type {
userId: string;
calId: string;
color: string;
name: string;
desc: string;
owner: string;
ownerEmails: string[];
}, // Return type
{ userId: string; calId: string; color: string; name: string; desc: string } // Arg type { userId: string; calId: string; color: string; name: string; desc: string } // Arg type
>("calendars/createCalendar", async ({ userId, calId, color, name, desc }) => { >("calendars/createCalendar", async ({ userId, calId, color, name, desc }) => {
const response = await postCalendar(userId, calId, color, name, desc); const response = await postCalendar(userId, calId, color, name, desc);
return { userId, calId, color, name, desc }; const ownerData: any = await getUserDetails(userId.split("/")[0]);
return {
userId,
calId,
color,
name,
desc,
owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${
ownerData.lastname
}`,
ownerEmails: ownerData.emails,
};
});
export const addSharedCalendarAsync = createAsyncThunk<
{
calId: string;
color: string;
name: string;
desc: string;
owner: string;
ownerEmails: string[];
}, // Return type
{ userId: string; calId: string; cal: Record<string, any> } // Arg type
>("calendars/addSharedCalendar", async ({ userId, calId, cal }) => {
const response = await addSharedCalendar(userId, calId, cal);
const ownerData: any = await getUserDetails(
cal.cal._links.self.href
.replace("/calendars/", "")
.replace(".json", "")
.split("/")[0]
);
return {
calId: cal.cal._links.self.href
.replace("/calendars/", "")
.replace(".json", ""),
color: cal.cal["apple:color"],
desc: cal.cal["caldav:description"],
name: cal.cal["dav:name"],
owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${
ownerData.lastname
}`,
ownerEmails: ownerData.emails,
};
}); });
const CalendarSlice = createSlice({ const CalendarSlice = createSlice({
@@ -363,7 +417,10 @@ const CalendarSlice = createSlice({
id: `${action.payload.userId}/${action.payload.calId}`, id: `${action.payload.userId}/${action.payload.calId}`,
description: action.payload.desc, description: action.payload.desc,
name: action.payload.name, name: action.payload.name,
} as unknown as Calendars; owner: action.payload.owner,
ownerEmails: action.payload.ownerEmails,
events: {},
} as Calendars;
}) })
.addCase(patchCalendarAsync.fulfilled, (state, action) => { .addCase(patchCalendarAsync.fulfilled, (state, action) => {
state.pending = false; state.pending = false;
@@ -391,6 +448,18 @@ const CalendarSlice = createSlice({
}; };
} }
}) })
.addCase(addSharedCalendarAsync.fulfilled, (state, action) => {
state.pending = false;
state.list[action.payload.calId] = {
color: action.payload.color,
id: action.payload.calId,
description: action.payload.desc,
name: action.payload.name,
events: {},
owner: action.payload.owner,
ownerEmails: action.payload.ownerEmails,
} as Calendars;
})
.addCase(getCalendarDetailAsync.pending, (state) => { .addCase(getCalendarDetailAsync.pending, (state) => {
state.pending = true; state.pending = true;
}) })
@@ -414,6 +483,9 @@ const CalendarSlice = createSlice({
}) })
.addCase(createCalendarAsync.pending, (state) => { .addCase(createCalendarAsync.pending, (state) => {
state.pending = true; state.pending = true;
})
.addCase(addSharedCalendarAsync.pending, (state) => {
state.pending = true;
}); });
}, },
}); });
+1 -4
View File
@@ -391,10 +391,7 @@ export default function EventDisplayModal({
{isOwn && ( {isOwn && (
<AttendeeSelector <AttendeeSelector
attendees={attendees} attendees={attendees}
setAttendees={(value: userAttendee[]) => { setAttendees={setAttendees}
const newAttendeeList = attendees.concat(value);
setAttendees(newAttendeeList);
}}
/> />
)} )}
+13 -2
View File
@@ -5,12 +5,22 @@ export async function getOpenPaasUser() {
return user; return user;
} }
export async function searchUsers(query: string) { export async function searchUsers(
query: string,
objectTypes: string[] = ["user", "contact"]
): Promise<
{
email: string;
displayName: string;
avatarUrl: string;
openpaasId: string;
}[]
> {
const response: any[] = await api const response: any[] = await api
.post(`api/people/search`, { .post(`api/people/search`, {
body: JSON.stringify({ body: JSON.stringify({
limit: 10, limit: 10,
objectTypes: ["user", "group", "contact", "ldap"], objectTypes,
q: query, q: query,
}), }),
}) })
@@ -21,6 +31,7 @@ export async function searchUsers(query: string) {
displayName: displayName:
user.names?.[0]?.displayName || user.emailAddresses?.[0]?.value, user.names?.[0]?.displayName || user.emailAddresses?.[0]?.value,
avatarUrl: user.photos?.[0]?.url || "", avatarUrl: user.photos?.[0]?.url || "",
openpaasId: user.id || "",
})); }));
} }