Migrate Dashboard interface patterns

This commit is contained in:
2026-08-03 14:25:02 +02:00
parent 4280e42110
commit da3947f417
9 changed files with 405 additions and 21 deletions
+24 -5
View File
@@ -13,6 +13,7 @@ import {
import {
Button,
DismissibleAlert,
DocumentationHelpLink,
LoadingFrame,
MetricCard,
PageScrollViewport,
@@ -36,6 +37,10 @@ import {
import DashboardGrid from "./DashboardGrid";
import WidgetConfigurationDialog from "./WidgetConfigurationDialog";
import WidgetLibrary from "./WidgetLibrary";
import {
DASHBOARD_DOCUMENTATION,
DASHBOARD_I18N
} from "./interfacePatterns";
import {
appendPlacement,
DASHBOARD_COLUMN_COUNT,
@@ -184,8 +189,8 @@ export default function DashboardPage({
dirty,
onSave: persistLayout,
onDiscard: discardChanges,
title: "Unsaved Dashboard layout",
message: "Save or discard the Dashboard arrangement before leaving this page."
title: "i18n:govoplan-dashboard.unsaved_layout_title",
message: "i18n:govoplan-dashboard.unsaved_layout_message"
});
const activeLayout = configuring ? draftLayout : savedLayout;
@@ -446,25 +451,39 @@ export default function DashboardPage({
<p>Personal workspace assembled from installed module widgets.</p>
</div>
<div className="button-row compact-actions">
<DocumentationHelpLink reference={DASHBOARD_DOCUMENTATION} />
{!configuring && (
<>
<Button onClick={() => setRefreshKey((value) => value + 1)}>
<Button
onClick={() => setRefreshKey((value) => value + 1)}
disabled={loading}
disabledReason={loading ? DASHBOARD_I18N.loading : undefined}
>
<RefreshCw size={16} /> Refresh
</Button>
<Button onClick={beginConfiguration}>
<Button
onClick={beginConfiguration}
disabled={loading}
disabledReason={loading ? DASHBOARD_I18N.loading : undefined}
>
<SlidersHorizontal size={16} /> Configure
</Button>
</>
)}
{configuring && (
<>
<Button onClick={discardChanges} disabled={saving}>
<Button
onClick={discardChanges}
disabled={saving}
disabledReason={saving ? DASHBOARD_I18N.saving : undefined}
>
<X size={16} /> Cancel
</Button>
<Button
variant="primary"
onClick={() => void persistLayout()}
disabled={saving || !dirty}
disabledReason={saving ? DASHBOARD_I18N.saving : !dirty ? DASHBOARD_I18N.noChanges : undefined}
>
<Save size={16} /> {saving ? "Saving..." : "Save layout"}
</Button>
@@ -2,9 +2,12 @@ import { useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DocumentationHelpLink,
FormField,
SegmentedControl,
ToggleSwitch,
useUnsavedChanges,
useUnsavedDraftGuard,
type DashboardWidgetConfiguration,
type DashboardWidgetConfigurationField,
type DashboardWidgetConfigurationValue,
@@ -16,6 +19,10 @@ import {
supportedWidgetSizes,
type DashboardWidgetPlacement
} from "./dashboardLayout";
import {
DASHBOARD_I18N,
DASHBOARD_LAYOUT_DOCUMENTATION
} from "./interfacePatterns";
type WidgetConfigurationDialogProps = {
open: boolean;
@@ -32,8 +39,25 @@ export default function WidgetConfigurationDialog({
onClose,
onSave
}: WidgetConfigurationDialogProps) {
const { requestDiscard } = useUnsavedChanges();
const [size, setSize] = useState<DashboardWidgetSize>("medium");
const [configuration, setConfiguration] = useState<DashboardWidgetConfiguration>({});
const savedConfiguration = useMemo(
() => ({
...(widget?.defaultConfiguration ?? {}),
...(placement?.configuration ?? {})
}),
[placement, widget]
);
const dirty = Boolean(
open
&& widget
&& placement
&& (
size !== placement.size
|| JSON.stringify(configuration) !== JSON.stringify(savedConfiguration)
)
);
useEffect(() => {
if (!open || !widget || !placement) return;
@@ -58,6 +82,34 @@ export default function WidgetConfigurationDialog({
[configuration, widget]
);
function discardDraft() {
if (!widget || !placement) return;
setSize(placement.size);
setConfiguration({
...(widget.defaultConfiguration ?? {}),
...placement.configuration
});
}
function applyDraft(): boolean {
if (!placement || invalidConfiguration) return false;
onSave({ ...placement, size, configuration });
return true;
}
useUnsavedDraftGuard({
dirty,
title: "i18n:govoplan-dashboard.unsaved_widget_title",
message: "i18n:govoplan-dashboard.unsaved_widget_message",
onSave: applyDraft,
onDiscard: discardDraft
});
function close() {
if (dirty) requestDiscard(onClose);
else onClose();
}
if (!widget || !placement) return null;
const supportedSizes = supportedWidgetSizes(widget);
@@ -74,26 +126,28 @@ export default function WidgetConfigurationDialog({
<Dialog
open={open}
title={`Configure ${widget.title}`}
onClose={onClose}
onClose={close}
className="dashboard-widget-config-dialog"
footer={
<>
<Button type="button" variant="ghost" onClick={reset}>Reset</Button>
<span className="dialog-footer-spacer" />
<Button type="button" onClick={onClose}>Cancel</Button>
<Button type="button" onClick={close}>Cancel</Button>
<Button
type="button"
variant="primary"
disabled={invalidConfiguration}
onClick={() => onSave({ ...placement, size, configuration })}
disabledReason={invalidConfiguration ? DASHBOARD_I18N.invalidWidget : undefined}
onClick={applyDraft}
>
Apply
</Button>
</>
}
>
<DocumentationHelpLink reference={DASHBOARD_LAYOUT_DOCUMENTATION} />
{supportedSizes.length > 1 && (
<FormField label="Widget size">
<FormField label="Widget size" documentation={DASHBOARD_LAYOUT_DOCUMENTATION}>
<SegmentedControl
options={supportedSizes.map((candidate) => ({
id: candidate,
@@ -142,7 +196,7 @@ function ConfigurationField({
if (field.kind === "select") {
return (
<FormField label={field.label} help={field.description}>
<FormField label={field.label} help={field.description} documentation={DASHBOARD_LAYOUT_DOCUMENTATION}>
<select
value={typeof value === "string" ? value : ""}
required={field.required}
@@ -161,7 +215,7 @@ function ConfigurationField({
if (field.kind === "number") {
return (
<FormField label={field.label} help={field.description}>
<FormField label={field.label} help={field.description} documentation={DASHBOARD_LAYOUT_DOCUMENTATION}>
<input
type="number"
value={typeof value === "number" ? value : ""}
@@ -180,7 +234,7 @@ function ConfigurationField({
}
return (
<FormField label={field.label} help={field.description}>
<FormField label={field.label} help={field.description} documentation={DASHBOARD_LAYOUT_DOCUMENTATION}>
<input
type="text"
value={typeof value === "string" ? value : ""}
@@ -66,6 +66,7 @@ export default function WidgetLibrary({
label={`Add ${widget.title}`}
icon={<Plus size={16} />}
variant="ghost"
disabled={atCapacity}
disabledReason={
atCapacity
? "A Dashboard can contain at most 100 widgets."
@@ -0,0 +1,18 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const DASHBOARD_DOCUMENTATION = {
topicId: "dashboard.configurable-home",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const DASHBOARD_LAYOUT_DOCUMENTATION = {
topicId: "dashboard.reference.layout-and-widgets",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const DASHBOARD_I18N = {
loading: "i18n:govoplan-dashboard.loading_reason",
saving: "i18n:govoplan-dashboard.saving_reason",
noChanges: "i18n:govoplan-dashboard.no_changes_reason",
invalidWidget: "i18n:govoplan-dashboard.invalid_widget_reason"
} as const;