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
@@ -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>
);
}
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<HTMLElement | null>(null);
const [anchorElCalOthers, setAnchorElCalOthers] =
useState<HTMLElement | null>(null);
return (
<>
<div>
@@ -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({
}}
/>
</div>
<CalendarPopover
anchorEl={anchorElCal}
open={Boolean(anchorElCal)}
calendar={calendars[selectedCalId] ?? undefined}
onClose={() => {
setSelectedCalId("");
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));
}
}}
/>
</>
);