feat(dashboard): customise widget visibility order and size
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useBlocker, useBeforeUnload } from "react-router-dom";
|
||||
import { Alert, Box, Button, Checkbox, FormControlLabel, Stack, TextField, Typography } from "@mui/material";
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import { DashboardLayout, defaultDashboardLayout, WIDGETS } from "../dashboardLayout";
|
||||
import { useDragReorder } from "../hooks/useDragReorder";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
export function DashboardNavigationGuard({ dirty, saving }: { dirty: boolean; saving: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const { confirm } = useConfirm();
|
||||
const blocker = useBlocker(dirty || saving);
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
const pending = useRef(false);
|
||||
useBeforeUnload(React.useCallback(event => {
|
||||
if (!dirty) return;
|
||||
event.preventDefault(); event.returnValue = "";
|
||||
}, [dirty]));
|
||||
useEffect(() => {
|
||||
if (blocker.state !== "blocked") { pending.current = false; return; }
|
||||
if (saving) { setWaiting(true); blocker.reset(); return; }
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
void confirm({ title: t("dashboardDiscardTitle"), message: t("dashboardDiscardBody"), confirmLabel: t("dashboardDiscard"), cancelLabel: t("workspaceKeepEditing"), destructive: true })
|
||||
.then(approved => { if (approved) blocker.proceed(); else blocker.reset(); });
|
||||
}, [blocker, confirm, saving, t]);
|
||||
return waiting && saving ? <Typography role="status">{t("dashboardWaitForSave")}</Typography> : null;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
draft: DashboardLayout;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
saveError: string | null;
|
||||
conflict: boolean;
|
||||
onChange: (next: DashboardLayout) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onReload: () => void;
|
||||
};
|
||||
|
||||
export default function DashboardLayoutEditor({ draft, dirty, saving, saveError, conflict, onChange, onSave, onCancel, onReload }: Props) {
|
||||
const { t } = useI18n();
|
||||
const { confirm } = useConfirm();
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const pendingFocus = useRef<{ id: string; direction: number } | null>(null);
|
||||
const [announcement, announce] = useState("");
|
||||
useEffect(() => {
|
||||
panel.current?.focus({ preventScroll: true });
|
||||
panel.current?.scrollIntoView?.({ block: "start" });
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
const target = pendingFocus.current;
|
||||
if (!target) return;
|
||||
pendingFocus.current = null;
|
||||
const row = panel.current?.querySelector(`[data-layout-row="${target.id}"]`);
|
||||
const preferred = row?.querySelector<HTMLButtonElement>(`button[data-move="${target.direction}"]:not(:disabled)`);
|
||||
(preferred ?? row?.querySelector<HTMLButtonElement>("button[data-move]:not(:disabled)"))?.focus();
|
||||
}, [draft]);
|
||||
|
||||
const reorder = (from: number, to: number) => {
|
||||
if (saving || from === to || to < 0 || to >= draft.widgets.length) return;
|
||||
const widgets = [...draft.widgets];
|
||||
const [moved] = widgets.splice(from, 1); widgets.splice(to, 0, moved);
|
||||
onChange({ ...draft, widgets });
|
||||
const name = t(WIDGETS.find(widget => widget.id === moved.id)!.titleKey);
|
||||
announce(t("dashboardMoved", { name, position: to + 1, total: widgets.length }));
|
||||
};
|
||||
const drag = useDragReorder(reorder);
|
||||
const change = (id: string, patch: Partial<DashboardLayout["widgets"][number]>) => onChange({ ...draft, widgets: draft.widgets.map(widget => widget.id === id ? { ...widget, ...patch } : widget) });
|
||||
const cancel = async () => {
|
||||
if (!dirty || await confirm({ title: t("dashboardDiscardTitle"), message: t("dashboardDiscardBody"), confirmLabel: t("dashboardDiscard"), destructive: true })) onCancel();
|
||||
};
|
||||
const reset = async () => {
|
||||
if (await confirm({ title: t("dashboardReset"), message: t("dashboardResetBody"), confirmLabel: t("dashboardReset") })) onChange({ ...defaultDashboardLayout(), revision: draft.revision });
|
||||
};
|
||||
const reload = async () => {
|
||||
if (await confirm({ title: t("dashboardReload"), message: t("dashboardReloadBody"), confirmLabel: t("dashboardReload"), destructive: true })) onReload();
|
||||
};
|
||||
return <Box ref={panel} tabIndex={-1} role="group" aria-label={t("dashboardEditing")} sx={{ scrollMarginTop: "calc(var(--app-shell-header-height, 124px) + 16px)" }} onKeyDown={event => {
|
||||
if (event.key === "Escape" && !saving && !event.defaultPrevented) { event.preventDefault(); void cancel(); }
|
||||
}}>
|
||||
<PageSection surface title={t("dashboardEditing")} description={t("dashboardEditingBody")}>
|
||||
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1} sx={{ mb: 2 }}>
|
||||
<Button variant="contained" disabled={saving || conflict} onClick={onSave}>{t(saving ? "dashboardSaving" : "dashboardSave")}</Button>
|
||||
<Button disabled={saving} onClick={() => void cancel()}>{t("cancel")}</Button>
|
||||
<Button disabled={saving} onClick={() => void reset()}>{t("dashboardReset")}</Button>
|
||||
</Stack>
|
||||
{saveError ? <Alert severity="error" sx={{ mb: 2 }}>{t(conflict ? "dashboardConflict" : "dashboardSaveFailed")}
|
||||
{conflict ? <Button disabled={saving} onClick={() => void reload()}>{t("dashboardReload")}</Button> : null}
|
||||
</Alert> : null}
|
||||
<Typography role="status" aria-live="polite" variant="body2" sx={{ mb: 1 }}>{announcement || t(dirty ? "assetsUnsavedChanges" : "dashboardDraftSaved")}</Typography>
|
||||
<Box component="ol" aria-label={t("dashboardManageWidgets")} sx={{ listStyle: "none", m: 0, p: 0, display: "grid", gap: 1 }}>
|
||||
{draft.widgets.map((widget, index) => {
|
||||
const entry = WIDGETS.find(item => item.id === widget.id)!;
|
||||
const name = t(entry.titleKey);
|
||||
const gesture = drag.getItemProps(index);
|
||||
return <Box component="li" key={widget.id} data-layout-row={widget.id} onDragOver={saving ? undefined : gesture.onDragOver} onDrop={saving ? undefined : gesture.onDrop}
|
||||
sx={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 1, p: 1, border: "1px solid", borderColor: drag.overIndex === index ? "primary.main" : "divider", borderRadius: 1, bgcolor: drag.overIndex === index ? "action.hover" : "background.paper", opacity: drag.dragIndex === index ? 0.6 : 1, minWidth: 0 }}>
|
||||
<Button {...gesture} disabled={saving} draggable={!saving} aria-label={t("dashboardDrag", { name })} aria-describedby="dashboard-drag-help" sx={{ minWidth: 44, minHeight: 44, cursor: "grab" }}><DragIndicatorIcon /></Button>
|
||||
<FormControlLabel sx={{ flex: "1 1 14rem", minWidth: 0, mr: 0, overflowWrap: "anywhere" }} control={<Checkbox checked={widget.visible} disabled={saving} onChange={event => change(widget.id, { visible: event.target.checked })} />} label={name} />
|
||||
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap">
|
||||
{([-1, 1] as const).map(direction => <Button key={direction} data-move={direction} disabled={saving || index + direction < 0 || index + direction >= draft.widgets.length}
|
||||
aria-label={t(direction === -1 ? "dashboardMoveEarlierNamed" : "dashboardMoveLaterNamed", { name })}
|
||||
onClick={() => { pendingFocus.current = { id: widget.id, direction }; reorder(index, index + direction); }} sx={{ minHeight: 44 }}>{t(direction === -1 ? "dashboardEarlier" : "dashboardLater")}</Button>)}
|
||||
</Stack>
|
||||
<TextField select size="small" label={t("dashboardSize")} value={widget.size} disabled={saving || entry.sizes.length === 1}
|
||||
onChange={event => change(widget.id, { size: event.target.value as "standard" | "full" })}
|
||||
slotProps={{ select: { native: true }, htmlInput: { "aria-label": t("dashboardSizeFor", { name }) } }} sx={{ width: { xs: "100%", sm: 190 }, minWidth: 0, "& select": { minHeight: 28 } }}>
|
||||
{entry.sizes.map(size => <option key={size} value={size}>{t(size === "full" ? "dashboardSizeFull" : "dashboardSizeStandard")}</option>)}
|
||||
</TextField>
|
||||
</Box>;
|
||||
})}
|
||||
</Box>
|
||||
<Typography id="dashboard-drag-help" variant="body2" color="text.secondary" sx={{ mt: 2 }}>{t("dashboardReorderHelp")}</Typography>
|
||||
</PageSection>
|
||||
</Box>;
|
||||
}
|
||||
Reference in New Issue
Block a user