87 lines
4.8 KiB
TypeScript
87 lines
4.8 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
|
import { useBlocker, useBeforeUnload } from "react-router-dom";
|
|
import { Alert, Box, Button, Checkbox, FormControlLabel, Stack, Typography } from "@mui/material";
|
|
import { arrangeDashboardWidgets, DashboardLayout, defaultDashboardLayout, WIDGETS } from "../dashboardLayout";
|
|
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);
|
|
useEffect(() => {
|
|
panel.current?.focus({ preventScroll: true });
|
|
panel.current?.scrollIntoView?.({ block: "start" });
|
|
}, []);
|
|
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 }}>{t(dirty ? "assetsUnsavedChanges" : "dashboardDraftSaved")}</Typography>
|
|
<Box component="details">
|
|
<Box component="summary" sx={{ p: 1, cursor: "pointer", minHeight: 44 }}>{t("dashboardManageWidgets")}</Box>
|
|
<Stack direction="row" useFlexGap flexWrap="wrap" role="group" aria-label={t("dashboardManageWidgets")}>
|
|
{draft.widgets.map(widget => {
|
|
const entry = WIDGETS.find(item => item.id === widget.id)!;
|
|
const name = t(entry.titleKey);
|
|
return <FormControlLabel key={widget.id} sx={{ minWidth: 0, overflowWrap: "anywhere" }} label={name}
|
|
control={<Checkbox checked={widget.visible} disabled={saving || conflict} onChange={event => onChange({ ...draft,
|
|
widgets: arrangeDashboardWidgets(draft.widgets.map(item => item.id === widget.id ? { ...item, visible: event.target.checked } : item)) })} />} />;
|
|
})}
|
|
</Stack>
|
|
</Box>
|
|
</PageSection>
|
|
</Box>;
|
|
}
|