feat: add configurable view-aware dashboards
This commit is contained in:
@@ -1,29 +1,72 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { RefreshCw, RotateCcw, SlidersHorizontal } from "lucide-react";
|
||||
import {
|
||||
AdminSelectionList,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type DragEvent as ReactDragEvent
|
||||
} from "react";
|
||||
import {
|
||||
RefreshCw,
|
||||
Save,
|
||||
SlidersHorizontal,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DismissibleAlert,
|
||||
LoadingFrame,
|
||||
MetricCard,
|
||||
PageScrollViewport,
|
||||
PageTitle,
|
||||
dashboardWidgetsForModules,
|
||||
hasAnyScope,
|
||||
hasScope,
|
||||
isApiError,
|
||||
useEffectiveView,
|
||||
usePlatformModules,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DashboardWidgetContribution,
|
||||
type DashboardWidgetSize
|
||||
type DashboardWidgetContribution
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
fetchDashboardLayout,
|
||||
saveDashboardLayout
|
||||
} from "../../api/dashboard";
|
||||
import DashboardGrid from "./DashboardGrid";
|
||||
import WidgetConfigurationDialog from "./WidgetConfigurationDialog";
|
||||
import WidgetLibrary from "./WidgetLibrary";
|
||||
import {
|
||||
appendPlacement,
|
||||
defaultDashboardLayout,
|
||||
insertPlacement,
|
||||
layoutFromResponse,
|
||||
layoutUpdatePayload,
|
||||
layoutsEqual,
|
||||
localLayoutKey,
|
||||
MAX_DASHBOARD_WIDGETS,
|
||||
readLocalLayout,
|
||||
reconcileDashboardLayout,
|
||||
removePlacement,
|
||||
reorderPlacement,
|
||||
updatePlacement,
|
||||
writeLocalLayout,
|
||||
type DashboardLayoutState,
|
||||
type DashboardWidgetPlacement
|
||||
} from "./dashboardLayout";
|
||||
import type {
|
||||
DashboardDragItem,
|
||||
DashboardDropTarget
|
||||
} from "./dashboardEditorTypes";
|
||||
|
||||
type DashboardLayout = {
|
||||
visible: string[];
|
||||
known: string[];
|
||||
};
|
||||
type LayoutSource = "server" | "browser" | "default";
|
||||
|
||||
export default function DashboardPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||
export default function DashboardPage({
|
||||
settings,
|
||||
auth
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
}) {
|
||||
const modules = usePlatformModules();
|
||||
const effectiveView = useEffectiveView();
|
||||
const widgets = useMemo(
|
||||
@@ -33,150 +76,485 @@ export default function DashboardPage({ settings, auth }: { settings: ApiSetting
|
||||
),
|
||||
[auth, effectiveView, modules]
|
||||
);
|
||||
const widgetSignature = widgets.map((widget) => widget.id).join("|");
|
||||
const storageKey = `govoplan.dashboard.layout:${auth.active_tenant?.id ?? auth.tenant.id}:${auth.user.id}`;
|
||||
const [layout, setLayout] = useState<DashboardLayout>(() => defaultLayout(widgets));
|
||||
const widgetSignature = JSON.stringify(
|
||||
widgets.map((widget) => ({
|
||||
id: widget.id,
|
||||
allowMultiple: widget.allowMultiple ?? false,
|
||||
defaultVisible: widget.defaultVisible ?? true,
|
||||
defaultSize: widget.defaultSize ?? "medium",
|
||||
supportedSizes: widget.supportedSizes ?? [],
|
||||
defaultConfiguration: widget.defaultConfiguration ?? {}
|
||||
}))
|
||||
);
|
||||
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
|
||||
const accountId = auth.user.account_id || auth.user.id;
|
||||
const viewId = effectiveView?.activeViewId ?? null;
|
||||
const storageKey = localLayoutKey(tenantId, accountId, viewId);
|
||||
const legacyStorageKey = viewId === null
|
||||
? `govoplan.dashboard.layout:${tenantId}:${auth.user.id}`
|
||||
: null;
|
||||
const initialLayout = useMemo(
|
||||
() => defaultDashboardLayout(widgets),
|
||||
[widgets]
|
||||
);
|
||||
const [savedLayout, setSavedLayout] = useState<DashboardLayoutState>(initialLayout);
|
||||
const [draftLayout, setDraftLayout] = useState<DashboardLayoutState>(initialLayout);
|
||||
const [layoutSource, setLayoutSource] = useState<LayoutSource>("default");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [configuring, setConfiguring] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [libraryQuery, setLibraryQuery] = useState("");
|
||||
const [dragItem, setDragItem] = useState<DashboardDragItem | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<DashboardDropTarget | null>(null);
|
||||
const [editingInstanceId, setEditingInstanceId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLayout(readLayout(storageKey) ?? defaultLayout(widgets));
|
||||
}, [storageKey, widgetSignature]);
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
setConfiguring(false);
|
||||
setEditingInstanceId(null);
|
||||
|
||||
const visibleWidgetIds = useMemo(() => {
|
||||
const known = new Set(layout.known);
|
||||
const visible = new Set(layout.visible);
|
||||
for (const widget of widgets) {
|
||||
if (!known.has(widget.id) && widget.defaultVisible !== false) visible.add(widget.id);
|
||||
void fetchDashboardLayout(settings, viewId)
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
const local = readLocalLayout(storageKey, legacyStorageKey, widgets);
|
||||
const source: LayoutSource = response.exists
|
||||
? "server"
|
||||
: local
|
||||
? "browser"
|
||||
: "default";
|
||||
const base = response.exists
|
||||
? layoutFromResponse(response)
|
||||
: local
|
||||
? { ...local, revision: 0 }
|
||||
: defaultDashboardLayout(widgets);
|
||||
const next = reconcileDashboardLayout(base, widgets);
|
||||
setSavedLayout(next);
|
||||
setDraftLayout(next);
|
||||
setLayoutSource(source);
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (!active) return;
|
||||
const local = readLocalLayout(storageKey, legacyStorageKey, widgets);
|
||||
const next = reconcileDashboardLayout(
|
||||
local ?? defaultDashboardLayout(widgets),
|
||||
widgets
|
||||
);
|
||||
setSavedLayout(next);
|
||||
setDraftLayout(next);
|
||||
setLayoutSource(local ? "browser" : "default");
|
||||
setError(
|
||||
`The saved Dashboard layout could not be loaded. ${
|
||||
local
|
||||
? "The browser fallback is shown."
|
||||
: "The module defaults are shown."
|
||||
} ${errorMessage(reason)}`
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [
|
||||
legacyStorageKey,
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey,
|
||||
storageKey,
|
||||
viewId,
|
||||
widgetSignature
|
||||
]);
|
||||
|
||||
const dirty = configuring && !layoutsEqual(savedLayout, draftLayout);
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: persistLayout,
|
||||
onDiscard: discardChanges,
|
||||
title: "Unsaved Dashboard layout",
|
||||
message: "Save or discard the Dashboard arrangement before leaving this page."
|
||||
});
|
||||
|
||||
const activeLayout = configuring ? draftLayout : savedLayout;
|
||||
const widgetById = useMemo(
|
||||
() => new Map(widgets.map((widget) => [widget.id, widget])),
|
||||
[widgets]
|
||||
);
|
||||
const renderedPlacements = activeLayout.placements.filter((placement) =>
|
||||
widgetById.has(placement.widgetId)
|
||||
);
|
||||
const placedWidgetIds = new Set(
|
||||
draftLayout.placements.map((placement) => placement.widgetId)
|
||||
);
|
||||
const libraryWidgets = widgets.filter((widget) => {
|
||||
if (!widget.allowMultiple && placedWidgetIds.has(widget.id)) return false;
|
||||
if (!libraryQuery.trim()) return true;
|
||||
const query = libraryQuery.trim().toLocaleLowerCase();
|
||||
return [widget.title, widget.description, widget.category, widget.moduleId, widget.id]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLocaleLowerCase().includes(query));
|
||||
});
|
||||
const hiddenCount = widgets.filter(
|
||||
(widget) => !placedWidgetIds.has(widget.id)
|
||||
).length;
|
||||
const editingPlacement = editingInstanceId
|
||||
? draftLayout.placements.find(
|
||||
(placement) => placement.instanceId === editingInstanceId
|
||||
) ?? null
|
||||
: null;
|
||||
const editingWidget = editingPlacement
|
||||
? widgetById.get(editingPlacement.widgetId) ?? null
|
||||
: null;
|
||||
const viewLabel = effectiveView?.activeViewName ?? "Full interface";
|
||||
|
||||
async function persistLayout(): Promise<boolean> {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
const response = await saveDashboardLayout(
|
||||
settings,
|
||||
viewId,
|
||||
layoutUpdatePayload(draftLayout)
|
||||
);
|
||||
const next = reconcileDashboardLayout(layoutFromResponse(response), widgets);
|
||||
setSavedLayout(next);
|
||||
setDraftLayout(next);
|
||||
setLayoutSource("server");
|
||||
setConfiguring(false);
|
||||
writeLocalLayout(storageKey, next);
|
||||
setNotice(`Dashboard layout saved for ${viewLabel}.`);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason));
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
const availableIds = new Set(widgets.map((widget) => widget.id));
|
||||
return widgets.map((widget) => widget.id).filter((id) => availableIds.has(id) && visible.has(id));
|
||||
}, [layout, widgets]);
|
||||
const visibleWidgets = widgets.filter((widget) => visibleWidgetIds.includes(widget.id));
|
||||
const hiddenCount = Math.max(widgets.length - visibleWidgets.length, 0);
|
||||
|
||||
function saveLayout(next: DashboardLayout) {
|
||||
setLayout(next);
|
||||
writeLayout(storageKey, next);
|
||||
}
|
||||
|
||||
function setVisibleWidgets(selected: string[]) {
|
||||
const allIds = widgets.map((widget) => widget.id);
|
||||
const nextVisible = new Set(selected);
|
||||
saveLayout({
|
||||
known: allIds,
|
||||
visible: allIds.filter((id) => nextVisible.has(id))
|
||||
});
|
||||
function discardChanges() {
|
||||
setDraftLayout(savedLayout);
|
||||
setConfiguring(false);
|
||||
setEditingInstanceId(null);
|
||||
setDragItem(null);
|
||||
setDropTarget(null);
|
||||
}
|
||||
|
||||
function resetLayout() {
|
||||
saveLayout(defaultLayout(widgets));
|
||||
function beginConfiguration() {
|
||||
setDraftLayout(savedLayout);
|
||||
setConfiguring(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
}
|
||||
|
||||
function updateDraft(updater: (layout: DashboardLayoutState) => DashboardLayoutState) {
|
||||
setDraftLayout((current) => updater(current));
|
||||
}
|
||||
|
||||
function addWidget(widget: DashboardWidgetContribution) {
|
||||
updateDraft((layout) => appendPlacement(layout, widget));
|
||||
}
|
||||
|
||||
function startDrag(event: ReactDragEvent, item: DashboardDragItem) {
|
||||
event.dataTransfer.effectAllowed = item.kind === "catalogue" ? "copy" : "move";
|
||||
event.dataTransfer.setData("application/x-govoplan-dashboard", JSON.stringify(item));
|
||||
setDragItem(item);
|
||||
setDropTarget(null);
|
||||
}
|
||||
|
||||
function finishDrag() {
|
||||
setDragItem(null);
|
||||
setDropTarget(null);
|
||||
}
|
||||
|
||||
function markDropTarget(
|
||||
event: ReactDragEvent<HTMLElement>,
|
||||
instanceId: string
|
||||
) {
|
||||
if (!dragItem) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = dragItem.kind === "catalogue" ? "copy" : "move";
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const verticalPosition = (event.clientY - bounds.top) / bounds.height;
|
||||
const edge =
|
||||
verticalPosition < 0.3
|
||||
|| (
|
||||
verticalPosition <= 0.7
|
||||
&& event.clientX < bounds.left + bounds.width / 2
|
||||
)
|
||||
? "before"
|
||||
: "after";
|
||||
setDropTarget((current) =>
|
||||
current?.kind === "placement"
|
||||
&& current.instanceId === instanceId
|
||||
&& current.edge === edge
|
||||
? current
|
||||
: { kind: "placement", instanceId, edge }
|
||||
);
|
||||
}
|
||||
|
||||
function markEndDropTarget(event: ReactDragEvent<HTMLElement>) {
|
||||
if (!dragItem) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = dragItem.kind === "catalogue" ? "copy" : "move";
|
||||
setDropTarget((current) =>
|
||||
current?.kind === "end" ? current : { kind: "end" }
|
||||
);
|
||||
}
|
||||
|
||||
function dropOnPlacement(
|
||||
event: ReactDragEvent<HTMLElement>,
|
||||
target: DashboardWidgetPlacement
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!dragItem) return;
|
||||
const edge = dropTarget?.kind === "placement"
|
||||
&& dropTarget.instanceId === target.instanceId
|
||||
? dropTarget.edge
|
||||
: "after";
|
||||
if (dragItem.kind === "placement") {
|
||||
updateDraft((layout) =>
|
||||
reorderPlacement(layout, dragItem.instanceId, target.instanceId, edge)
|
||||
);
|
||||
} else {
|
||||
const widget = widgetById.get(dragItem.widgetId);
|
||||
if (widget) {
|
||||
updateDraft((layout) =>
|
||||
insertPlacement(layout, widget, target.instanceId, edge)
|
||||
);
|
||||
}
|
||||
}
|
||||
finishDrag();
|
||||
}
|
||||
|
||||
function dropOnPreview(event: ReactDragEvent<HTMLElement>) {
|
||||
if (dropTarget?.kind === "placement") {
|
||||
const target = renderedPlacements.find(
|
||||
(placement) => placement.instanceId === dropTarget.instanceId
|
||||
);
|
||||
if (target) {
|
||||
dropOnPlacement(event, target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
dropAtEnd(event);
|
||||
}
|
||||
|
||||
function dropAtEnd(event: ReactDragEvent<HTMLElement>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!dragItem) return;
|
||||
if (dragItem.kind === "placement") {
|
||||
const last = draftLayout.placements.at(-1);
|
||||
if (last) {
|
||||
updateDraft((layout) =>
|
||||
reorderPlacement(layout, dragItem.instanceId, last.instanceId, "after")
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const widget = widgetById.get(dragItem.widgetId);
|
||||
if (widget) addWidget(widget);
|
||||
}
|
||||
finishDrag();
|
||||
}
|
||||
|
||||
return (
|
||||
<PageScrollViewport className="dashboard-page">
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>i18n:govoplan-dashboard.dashboard.3f8b4df2</PageTitle>
|
||||
<p>Personal workspace assembled from installed module widgets.</p>
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>i18n:govoplan-dashboard.dashboard.3f8b4df2</PageTitle>
|
||||
<p>Personal workspace assembled from installed module widgets.</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
{!configuring && (
|
||||
<>
|
||||
<Button onClick={() => setRefreshKey((value) => value + 1)}>
|
||||
<RefreshCw size={16} /> Refresh
|
||||
</Button>
|
||||
<Button onClick={beginConfiguration}>
|
||||
<SlidersHorizontal size={16} /> Configure
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{configuring && (
|
||||
<>
|
||||
<Button onClick={discardChanges} disabled={saving}>
|
||||
<X size={16} /> Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void persistLayout()}
|
||||
disabled={saving || !dirty}
|
||||
>
|
||||
<Save size={16} /> {saving ? "Saving..." : "Save layout"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => setRefreshKey((value) => value + 1)}><RefreshCw size={16} /> Refresh</Button>
|
||||
<Button onClick={() => setConfiguring((value) => !value)}><SlidersHorizontal size={16} /> Configure</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metric-grid dashboard-summary-grid">
|
||||
<MetricCard label="Installed modules" value={modules.length} tone="info" detail={moduleLabels(modules)} />
|
||||
<MetricCard label="Available widgets" value={widgets.length} tone="neutral" detail="Provided by active modules" />
|
||||
<MetricCard label="Visible widgets" value={visibleWidgets.length} tone="good" detail={hiddenCount ? `${hiddenCount} hidden` : "All shown"} />
|
||||
<MetricCard label="Layout" value="personal" tone="neutral" detail="Saved in this browser" />
|
||||
</div>
|
||||
|
||||
{configuring &&
|
||||
<Card
|
||||
title="Dashboard widgets"
|
||||
actions={<Button onClick={resetLayout}><RotateCcw size={16} /> Reset</Button>}>
|
||||
{widgets.length === 0 ? <p className="muted">No installed module exposes dashboard widgets yet.</p> :
|
||||
<AdminSelectionList
|
||||
options={widgets.map((widget) => ({ id: widget.id, label: widget.title, description: widget.description ?? widget.id }))}
|
||||
selected={visibleWidgetIds}
|
||||
onChange={setVisibleWidgets}
|
||||
/>
|
||||
}
|
||||
</Card>
|
||||
}
|
||||
|
||||
{visibleWidgets.length === 0 ?
|
||||
<Card title="No widgets selected">
|
||||
<p className="muted">Open Configure and select at least one widget. Modules can add more widgets by exposing the dashboard.widgets capability.</p>
|
||||
</Card> :
|
||||
<div className="dashboard-widget-grid">
|
||||
{visibleWidgets.map((widget) =>
|
||||
<section key={widget.id} className={`dashboard-widget dashboard-widget-${widgetSize(widget)}`}>
|
||||
<Card title={widget.title} collapsible collapseKey={`dashboard-widget:${widget.id}`}>
|
||||
{widget.render({
|
||||
settings,
|
||||
auth,
|
||||
modules,
|
||||
widgetId: widget.id,
|
||||
refreshKey,
|
||||
size: widgetSize(widget)
|
||||
})}
|
||||
</Card>
|
||||
</section>
|
||||
{error && (
|
||||
<DismissibleAlert tone="danger" resetKey={error} floating>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{notice && !error && (
|
||||
<DismissibleAlert tone="success" resetKey={notice} floating>
|
||||
{notice}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
|
||||
<div className="metric-grid dashboard-summary-grid">
|
||||
<MetricCard
|
||||
label="Installed modules"
|
||||
value={modules.length}
|
||||
tone="info"
|
||||
detail={moduleLabels(modules)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Available widgets"
|
||||
value={widgets.length}
|
||||
tone="neutral"
|
||||
detail="Provided by active modules"
|
||||
/>
|
||||
<MetricCard
|
||||
label="On dashboard"
|
||||
value={renderedPlacements.length}
|
||||
tone="good"
|
||||
detail={hiddenCount ? `${hiddenCount} available to add` : "All shown"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Layout"
|
||||
value={viewLabel}
|
||||
tone="neutral"
|
||||
detail={layoutSourceLabel(layoutSource)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading Dashboard layout">
|
||||
{configuring ? (
|
||||
<div className="dashboard-config-workspace">
|
||||
<WidgetLibrary
|
||||
widgets={libraryWidgets}
|
||||
atCapacity={draftLayout.placements.length >= MAX_DASHBOARD_WIDGETS}
|
||||
query={libraryQuery}
|
||||
onQueryChange={setLibraryQuery}
|
||||
onReset={() =>
|
||||
setDraftLayout({
|
||||
...defaultDashboardLayout(widgets),
|
||||
revision: savedLayout.revision
|
||||
})
|
||||
}
|
||||
onAdd={addWidget}
|
||||
onDragStart={startDrag}
|
||||
onDragEnd={finishDrag}
|
||||
/>
|
||||
|
||||
<DashboardGrid
|
||||
placements={renderedPlacements}
|
||||
widgetById={widgetById}
|
||||
settings={settings}
|
||||
auth={auth}
|
||||
modules={modules}
|
||||
effectiveView={effectiveView}
|
||||
refreshKey={refreshKey}
|
||||
configuring
|
||||
dragItem={dragItem}
|
||||
dropTarget={dropTarget}
|
||||
onDragStart={startDrag}
|
||||
onDragEnd={finishDrag}
|
||||
onDragOver={markDropTarget}
|
||||
onDragOverEnd={markEndDropTarget}
|
||||
onDrop={dropOnPlacement}
|
||||
onDropPreview={dropOnPreview}
|
||||
onDropAtEnd={dropAtEnd}
|
||||
onRemove={(instanceId) =>
|
||||
updateDraft((layout) => removePlacement(layout, instanceId))
|
||||
}
|
||||
onConfigure={setEditingInstanceId}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DashboardGrid
|
||||
placements={renderedPlacements}
|
||||
widgetById={widgetById}
|
||||
settings={settings}
|
||||
auth={auth}
|
||||
modules={modules}
|
||||
effectiveView={effectiveView}
|
||||
refreshKey={refreshKey}
|
||||
configuring={false}
|
||||
dragItem={null}
|
||||
dropTarget={null}
|
||||
onDragStart={startDrag}
|
||||
onDragEnd={finishDrag}
|
||||
onDragOver={markDropTarget}
|
||||
onDragOverEnd={markEndDropTarget}
|
||||
onDrop={dropOnPlacement}
|
||||
onDropPreview={dropOnPreview}
|
||||
onDropAtEnd={dropAtEnd}
|
||||
onRemove={() => undefined}
|
||||
onConfigure={() => undefined}
|
||||
/>
|
||||
)}
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
</PageScrollViewport>);
|
||||
|
||||
<WidgetConfigurationDialog
|
||||
open={Boolean(editingPlacement && editingWidget)}
|
||||
widget={editingWidget}
|
||||
placement={editingPlacement}
|
||||
onClose={() => setEditingInstanceId(null)}
|
||||
onSave={(placement) => {
|
||||
updateDraft((layout) => updatePlacement(layout, placement));
|
||||
setEditingInstanceId(null);
|
||||
}}
|
||||
/>
|
||||
</PageScrollViewport>
|
||||
);
|
||||
}
|
||||
|
||||
function canUseWidget(auth: AuthInfo, widget: DashboardWidgetContribution): boolean {
|
||||
if (widget.allOf?.length && !widget.allOf.every((scope) => hasScope(auth, scope))) return false;
|
||||
function canUseWidget(
|
||||
auth: AuthInfo,
|
||||
widget: DashboardWidgetContribution
|
||||
): boolean {
|
||||
if (
|
||||
widget.allOf?.length
|
||||
&& !widget.allOf.every((scope) => hasScope(auth, scope))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (widget.anyOf?.length && !hasAnyScope(auth, widget.anyOf)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function widgetSize(widget: DashboardWidgetContribution): DashboardWidgetSize {
|
||||
return widget.defaultSize ?? "medium";
|
||||
}
|
||||
|
||||
function defaultLayout(widgets: DashboardWidgetContribution[]): DashboardLayout {
|
||||
const ids = widgets.map((widget) => widget.id);
|
||||
return {
|
||||
known: ids,
|
||||
visible: widgets.filter((widget) => widget.defaultVisible !== false).map((widget) => widget.id)
|
||||
};
|
||||
}
|
||||
|
||||
function readLayout(storageKey: string): DashboardLayout | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<DashboardLayout>;
|
||||
if (!Array.isArray(parsed.visible) || !Array.isArray(parsed.known)) return null;
|
||||
return {
|
||||
visible: parsed.visible.filter((id): id is string => typeof id === "string"),
|
||||
known: parsed.known.filter((id): id is string => typeof id === "string")
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeLayout(storageKey: string, layout: DashboardLayout): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(layout));
|
||||
} catch {
|
||||
// localStorage may be unavailable in restricted browsing contexts.
|
||||
}
|
||||
}
|
||||
|
||||
function moduleLabels(modules: Array<{ id: string }>): string {
|
||||
if (!modules.length) return "Core shell only";
|
||||
return modules.slice(0, 4).map((module) => module.id).join(", ");
|
||||
}
|
||||
|
||||
function layoutSourceLabel(source: LayoutSource): string {
|
||||
if (source === "server") return "Saved for your account";
|
||||
if (source === "browser") return "Browser layout; save to synchronize";
|
||||
return "Module defaults";
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (isApiError(error)) {
|
||||
if (error.status === 409) {
|
||||
return "This layout changed in another session. Reload the page before saving again.";
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
return error instanceof Error ? error.message : "The Dashboard request failed.";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user