Revamp calendar dialog (#158)

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2025-10-07 11:14:08 +02:00
committed by GitHub
parent 2fa1dbb76b
commit 40c383c334
16 changed files with 1306 additions and 132 deletions
+37
View File
@@ -0,0 +1,37 @@
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { Box, IconButton, TextField } from "@mui/material";
import { useState } from "react";
import { Calendars } from "../../features/Calendars/CalendarTypes";
import { SnackbarAlert } from "../Loading/SnackBarAlert";
export function AccessTab({ calendar }: { calendar: Calendars }) {
const calDAVLink = `${(window as any).CALENDAR_BASE_URL}${calendar.link.replace(".json", "")}`;
const [open, setOpen] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(calDAVLink);
setOpen(true);
};
return (
<>
<Box mt={2}>
<TextField
disabled
fullWidth
label="CalDAV access"
value={calDAVLink}
slotProps={{
input: {
endAdornment: (
<IconButton onClick={handleCopy} edge="end">
<ContentCopyIcon />
</IconButton>
),
},
}}
/>
</Box>
<SnackbarAlert setOpen={setOpen} open={open} message="Link copied!" />
</>
);
}
+210 -98
View File
@@ -1,19 +1,17 @@
import { Button, DialogActions, Tab, Tabs } from "@mui/material";
import { useEffect, useState } from "react";
import {
createCalendarAsync /*, updateCalendarAsync */,
patchCalendarAsync,
} from "../../features/Calendars/CalendarSlice";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import {
TextField,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { ColorPicker } from "./CalendarColorPicker";
createCalendarAsync,
importEventFromFileAsync,
patchACLCalendarAsync,
patchCalendarAsync,
} from "../../features/Calendars/CalendarSlice";
import { Calendars } from "../../features/Calendars/CalendarTypes";
import { ResponsiveDialog } from "../Dialog";
import { AccessTab } from "./AccessTab";
import { ImportTab } from "./ImportTab";
import { SettingsTab } from "./SettingsTab";
function CalendarPopover({
open,
@@ -30,101 +28,215 @@ function CalendarPopover({
const dispatch = useAppDispatch();
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const [name, setName] = useState(calendar?.name ?? "");
const [description, setDescription] = useState(calendar?.description ?? "");
const [color, setColor] = useState(calendar?.color ?? "");
const calendars = useAppSelector((state) => state.calendars.list);
const isOwn = calendar ? calendar?.id.split("/")[0] === userId : true;
// existing calendar params
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [color, setColor] = useState("");
const [visibility, setVisibility] = useState<"private" | "public">("public");
// import tab state
const [tab, setTab] = useState<"settings" | "access" | "import">("settings");
const [importedContent, setImportedContent] = useState<File | null>(null);
const [importTarget, setImportTarget] = useState("new");
// new calendar params (for import new)
const [newCalName, setNewCalName] = useState("");
const [newCalDescription, setNewCalDescription] = useState("");
const [newCalColor, setNewCalColor] = useState("");
const [newCalVisibility, setNewCalVisibility] = useState<
"public" | "private"
>("public");
useEffect(() => {
if (open) {
if (calendar) {
setName(calendar.name);
setDescription(calendar.description ?? "");
setColor(calendar.color ?? "");
} else {
setName("");
setDescription("");
setColor("");
}
if (!open) return;
if (calendar) {
setName(calendar.name);
setDescription(calendar.description ?? "");
setColor(calendar.color ?? "");
setVisibility(calendar.visibility ?? "public");
setImportTarget(calendar.id ?? "new");
} else {
setName("");
setDescription("");
setColor("");
setVisibility("public");
setImportTarget("new");
}
}, [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 updateCalendar = (calId: string, calLink: string) => {
dispatch(
patchCalendarAsync({
calId,
calLink,
patch: { name: name.trim(), desc: description.trim(), color },
})
);
if (visibility !== calendar?.visibility) {
dispatch(
patchACLCalendarAsync({
calId,
calLink,
request: visibility === "public" ? "{DAV:}read" : "",
})
);
}
};
return (
<Dialog open={open} onClose={(e, reason) => onClose(e, reason)}>
<DialogTitle style={{ backgroundColor: color }}>
Calendar configuration
</DialogTitle>
<DialogContent>
<TextField
fullWidth
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
size="small"
margin="dense"
/>
<TextField
fullWidth
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
size="small"
margin="dense"
multiline
rows={2}
/>
<ColorPicker
onChange={(color) => setColor(color)}
selectedColor={color}
/>
</DialogContent>
const createCalendar = async (
calId: string,
name: string,
desc: string,
color: string,
visibility: string
) => {
await dispatch(
createCalendarAsync({
name: name.trim(),
desc: desc.trim(),
color: color,
userId,
calId,
})
);
dispatch(
patchACLCalendarAsync({
calId: `${userId}/${calId}`,
calLink: `/calendars/${userId}/${calId}.json`,
request: visibility === "public" ? "{DAV:}read" : "",
})
);
};
<DialogActions>
<Button
variant="outlined"
onClick={(e) => onClose({}, "backdropClick")}
>
Cancel
</Button>
<Button
disabled={!name.trim()}
variant="contained"
onClick={handleSave}
>
Save
</Button>
</DialogActions>
</Dialog>
const handleSave = () => {
if (!name.trim()) return;
if (calendar) {
updateCalendar(calendar.id, calendar.link);
} else {
createCalendar(crypto.randomUUID(), name, description, color, visibility);
}
handleClose({}, "backdropClick");
};
const handleImport = async () => {
if (importTarget === "new") {
const calId = crypto.randomUUID();
if (newCalName.trim()) {
await createCalendar(
calId,
newCalName,
newCalDescription,
newCalColor,
newCalVisibility
);
importedContent &&
dispatch(
importEventFromFileAsync({
calLink: `/calendar/${userId}/${calId}.json`,
file: importedContent,
})
);
}
} else {
importedContent &&
dispatch(
importEventFromFileAsync({
calLink: calendars[importTarget].link,
file: importedContent,
})
);
}
handleClose({}, "backdropClick");
};
const handleClose = (
e: {},
reason: "backdropClick" | "escapeKeyDown"
): void => {
onClose(e, reason);
setName("");
setDescription("");
setColor("");
setTab("settings");
setVisibility("public");
setImportTarget("new");
setImportedContent(null);
setNewCalName("");
setNewCalDescription("");
setNewCalColor("");
setNewCalVisibility("public");
};
return (
<ResponsiveDialog
open={open}
onClose={() => handleClose({}, "backdropClick")}
title={
<Tabs value={tab} onChange={(e, v) => setTab(v)}>
<Tab
value="settings"
label={calendar ? "Settings" : "Add new calendar"}
/>
{calendar && <Tab value="access" label="Access" />}
{isOwn && <Tab value="import" label="Import" />}
</Tabs>
}
actions={
<DialogActions>
<Button
variant="outlined"
onClick={(e) => handleClose({}, "backdropClick")}
>
Cancel
</Button>
<Button
disabled={tab === "import" ? !importedContent : !name.trim()}
variant="contained"
onClick={tab === "import" ? handleImport : handleSave}
>
{tab === "import" ? "Import" : calendar ? "Save" : "Create"}
</Button>
</DialogActions>
}
>
{tab === "import" && (
<ImportTab
importTarget={importTarget}
setImportTarget={setImportTarget}
setImportedContent={setImportedContent}
userId={userId}
newCalParams={{
name: newCalName,
setName: setNewCalName,
description: newCalDescription,
setDescription: setNewCalDescription,
color: newCalColor,
setColor: setNewCalColor,
visibility: newCalVisibility,
setVisibility: setNewCalVisibility,
}}
/>
)}
{tab === "settings" && (
<SettingsTab
name={name}
setName={setName}
description={description}
setDescription={setDescription}
color={color}
setColor={setColor}
visibility={visibility}
setVisibility={setVisibility}
calendar={calendar}
/>
)}
{tab === "access" && calendar && <AccessTab calendar={calendar} />}
</ResponsiveDialog>
);
}
+123
View File
@@ -0,0 +1,123 @@
import {
Box,
Button,
FormControl,
InputLabel,
MenuItem,
Select,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from "@mui/material";
import { useEffect, useState } from "react";
import { useAppSelector } from "../../app/hooks";
import { SettingsTab } from "./SettingsTab";
export function ImportTab({
userId,
importTarget,
setImportTarget,
setImportedContent,
newCalParams,
}: {
userId: string;
importTarget: string;
setImportTarget: Function;
setImportedContent: Function;
newCalParams: {
name: string;
setName: Function;
description: string;
setDescription: Function;
color: string;
setColor: Function;
visibility: "public" | "private";
setVisibility: Function;
};
}) {
const [importMode, setImportMode] = useState<"file" | "url">("file");
const [importFile, setImportFile] = useState<File | null>(null);
const [importUrl, setImportUrl] = useState("");
const calendars = useAppSelector((state) => state.calendars.list);
const personnalCalendars = Object.keys(calendars).filter(
(id) => id.split("/")[0] === userId
);
useEffect(() => {
setImportedContent(importMode === "file" ? importFile : null);
}, [importFile, importUrl, importMode]);
return (
<Box mt={2}>
<ToggleButtonGroup
value={importMode}
exclusive
onChange={(e, val) => val && setImportMode(val)}
fullWidth
size="small"
sx={{ mb: 2 }}
>
<ToggleButton value="file">File</ToggleButton>
{/* <ToggleButton value="url">URL</ToggleButton> */}
</ToggleButtonGroup>
{importMode === "file" && (
<>
<Button variant="outlined" component="label" sx={{ mb: 1 }}>
Select file
<input
type="file"
hidden
accept=".ics"
onChange={(e) => setImportFile(e.target.files?.[0] ?? null)}
/>
</Button>
{importFile && (
<Typography variant="body2" color="text.secondary">
{importFile.name}
</Typography>
)}
<Typography
variant="caption"
color="text.secondary"
display="block"
mb={2}
>
Import events from an ICS file to one of your calendars.
</Typography>
</>
)}
{importMode === "url" && (
<TextField
fullWidth
label="ICS feed URL"
value={importUrl}
onChange={(e) => setImportUrl(e.target.value)}
size="small"
margin="dense"
/>
)}
<FormControl fullWidth size="small" sx={{ mt: 2 }}>
<InputLabel id="import-to-label">Import to</InputLabel>
<Select
labelId="import-to-label"
label="Import to"
value={importTarget}
onChange={(e) => setImportTarget(e.target.value)}
>
<MenuItem value="new">New calendar</MenuItem>
{personnalCalendars.map((id) => (
<MenuItem key={id} value={id}>
{calendars[id].name}
</MenuItem>
))}
</Select>
</FormControl>
{importTarget === "new" && <SettingsTab {...newCalParams} />}
</Box>
);
}
+113
View File
@@ -0,0 +1,113 @@
import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
import LockIcon from "@mui/icons-material/Lock";
import PublicIcon from "@mui/icons-material/Public";
import {
Box,
Button,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from "@mui/material";
import { useState } from "react";
import { useAppSelector } from "../../app/hooks";
import { Calendars } from "../../features/Calendars/CalendarTypes";
import { ColorPicker } from "./CalendarColorPicker";
export function SettingsTab({
name,
setName,
description,
setDescription,
color,
setColor,
visibility,
setVisibility,
calendar,
}: {
name: string;
setName: Function;
description: string;
setDescription: Function;
color: string;
setColor: Function;
visibility: "public" | "private";
setVisibility: Function;
calendar?: Calendars;
}) {
const [toggleDesc, setToggleDesc] = useState(Boolean(description));
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const isOwn = calendar ? calendar.id.split("/")[0] === userId : true;
return (
<Box mt={2}>
<TextField
fullWidth
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
size="small"
margin="dense"
/>
{!toggleDesc && (
<Button
variant="outlined"
size="small"
onClick={() => setToggleDesc(!toggleDesc)}
startIcon={<FormatListBulletedIcon />}
>
Add description
</Button>
)}
{toggleDesc && (
<TextField
fullWidth
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
size="small"
margin="dense"
multiline
rows={2}
/>
)}
<Box mt={2}>
<Typography variant="body2" gutterBottom>
Color
</Typography>
<ColorPicker
onChange={(color) => setColor(color)}
selectedColor={color}
/>
</Box>
{isOwn && (
<Box mt={2}>
<Typography variant="body2" gutterBottom>
New events created will be visible to:
</Typography>
<ToggleButtonGroup
value={visibility}
exclusive
onChange={(e, val) => val && setVisibility(val)}
size="small"
>
<ToggleButton value="public">
<PublicIcon fontSize="small" />
All
</ToggleButton>
<ToggleButton value="private">
<LockIcon fontSize="small" />
You
</ToggleButton>
</ToggleButtonGroup>
</Box>
)}
</Box>
);
}
@@ -133,3 +133,26 @@ export const updateCalsDetails = (
});
}
};
interface AclEntry {
privilege: string;
principal: string;
protected: boolean;
}
export function getCalendarVisibility(acl: AclEntry[]): "private" | "public" {
let hasRead = false;
let hasFreeBusy = false;
for (const entry of acl) {
if (entry.principal !== "{DAV:}authenticated") continue;
if (entry.privilege === "{DAV:}read") {
hasRead = true;
break; // highest visibility, can stop
}
}
if (hasRead) return "public";
return "private";
}
+25
View File
@@ -0,0 +1,25 @@
import Alert from "@mui/material/Alert";
import Snackbar from "@mui/material/Snackbar";
export function SnackbarAlert({
open,
setOpen,
message,
}: {
open: boolean;
setOpen: Function;
message: string;
}) {
return (
<Snackbar
open={open}
autoHideDuration={2000}
onClose={() => setOpen(false)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert onClose={() => setOpen(false)} sx={{ width: "100%" }}>
{message}
</Alert>
</Snackbar>
);
}