Files
govoplan-dashboard/webui/src/features/dashboard/DashboardGrid.tsx
T
zemion 944401d53b fix(ui): keep dashboard configuration exit available and align heading help
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:03:36 +02:00

389 lines
11 KiB
TypeScript

import {
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type HTMLAttributes,
type DragEvent as ReactDragEvent,
type ReactNode
} from "react";
import {
GripVertical,
Settings2,
Trash2
} from "lucide-react";
import {
Card,
DocumentationHelpLink,
IconButton,
type ApiSettings,
type AuthInfo,
type DashboardWidgetContribution,
type EffectiveViewProjection,
type PlatformWebModule
} from "@govoplan/core-webui";
import {
dashboardMasonryRowSpan,
dashboardWidgetColumnSpan,
defaultWidgetSize,
widgetIsConfigurable,
type DashboardWidgetPlacement
} from "./dashboardLayout";
import {
dashboardGridPreview,
type DashboardDragItem,
type DashboardDropTarget
} from "./dashboardEditorTypes";
type DashboardGridProps = {
placements: DashboardWidgetPlacement[];
widgetById: Map<string, DashboardWidgetContribution>;
settings: ApiSettings;
auth: AuthInfo;
modules: PlatformWebModule[];
effectiveView: EffectiveViewProjection | null;
refreshKey: number;
configuring: boolean;
dragItem: DashboardDragItem | null;
dropTarget: DashboardDropTarget | null;
onDragStart: (event: ReactDragEvent, item: DashboardDragItem) => void;
onDragEnd: () => void;
onDragOver: (event: ReactDragEvent<HTMLElement>, instanceId: string) => void;
onDragOverEnd: (event: ReactDragEvent<HTMLElement>) => void;
onDrop: (
event: ReactDragEvent<HTMLElement>,
placement: DashboardWidgetPlacement
) => void;
onDropPreview: (event: ReactDragEvent<HTMLElement>) => void;
onDropAtEnd: (event: ReactDragEvent<HTMLElement>) => void;
onRemove: (instanceId: string) => void;
onConfigure: (instanceId: string) => void;
};
export default function DashboardGrid({
placements,
widgetById,
settings,
auth,
modules,
effectiveView,
refreshKey,
configuring,
dragItem,
dropTarget,
onDragStart,
onDragEnd,
onDragOver,
onDragOverEnd,
onDrop,
onDropPreview,
onDropAtEnd,
onRemove,
onConfigure
}: DashboardGridProps) {
const showingEmptyPreview =
configuring
&& dragItem?.kind === "catalogue"
&& dropTarget !== null;
if (!placements.length && !showingEmptyPreview) {
return (
<div
className={`dashboard-empty-state${configuring ? " is-drop-target" : ""}`}
onDragOver={(event) => {
if (!configuring) return;
onDragOverEnd(event);
}}
onDrop={configuring ? onDropAtEnd : undefined}
>
<h2>No widgets on this Dashboard</h2>
<p className="muted">
{configuring
? "Drag a widget here from the library."
: "Open Configure to add widgets provided by active modules."}
</p>
</div>
);
}
const catalogueWidget =
dragItem?.kind === "catalogue"
? widgetById.get(dragItem.widgetId)
: null;
const previewItems = dashboardGridPreview(
placements,
configuring ? dragItem : null,
configuring ? dropTarget : null,
catalogueWidget ? defaultWidgetSize(catalogueWidget) : "medium"
);
return (
<div
className={`dashboard-widget-grid${configuring ? " is-configuring" : ""}`}
data-dashboard-grid
onDragOver={(event) => {
if (!configuring || event.target !== event.currentTarget) return;
onDragOverEnd(event);
}}
onDrop={configuring ? onDropAtEnd : undefined}
>
{previewItems.map((item) => {
if (item.kind === "catalogue") {
const widget = widgetById.get(item.widgetId);
return (
<DashboardDropPlaceholder
key={`catalogue-placeholder:${item.widgetId}`}
title={widget?.title ?? "New widget"}
size={item.size}
columnStart={item.columnStart}
onDragOver={(event) => event.preventDefault()}
onDrop={onDropPreview}
/>
);
}
const placement = item.placement;
const widget = widgetById.get(placement.widgetId);
if (!widget) return null;
if (item.placeholder) {
return (
<DashboardDropPlaceholder
key={placement.instanceId}
title={widget.title}
size={placement.size}
columnStart={placement.columnStart}
preserveHeight
onDragOver={(event) => event.preventDefault()}
onDrop={onDropPreview}
>
<Card title={widget.title} titleHelp={widget.documentation && <DocumentationHelpLink reference={widget.documentation} />}>
<DashboardWidgetContent
widget={widget}
placement={placement}
settings={settings}
auth={auth}
modules={modules}
effectiveView={effectiveView}
refreshKey={refreshKey}
/>
</Card>
</DashboardDropPlaceholder>
);
}
return (
<DashboardMasonryItem
key={placement.instanceId}
className={`dashboard-widget dashboard-widget-${placement.size}`}
style={dashboardColumnStyle(placement.columnStart)}
onDragOver={
configuring
? (event) => onDragOver(event, placement.instanceId)
: undefined
}
onDrop={
configuring
? (event) => onDrop(event, placement)
: undefined
}
>
<Card
title={widget.title}
titleHelp={widget.documentation && <DocumentationHelpLink reference={widget.documentation} />}
collapsible={!configuring}
collapseKey={`dashboard-widget:${placement.instanceId}`}
actions={
configuring ? (
<div className="dashboard-widget-actions">
<IconButton
label={`Drag ${widget.title}`}
icon={<GripVertical size={16} />}
variant="ghost"
className="dashboard-widget-drag-handle"
draggable
onDragStart={(event) =>
onDragStart(event, {
kind: "placement",
instanceId: placement.instanceId
})
}
onDragEnd={onDragEnd}
/>
{widgetIsConfigurable(widget) && (
<IconButton
label={`Configure ${widget.title}`}
icon={<Settings2 size={16} />}
variant="ghost"
onClick={() => onConfigure(placement.instanceId)}
/>
)}
<IconButton
label={`Remove ${widget.title}`}
icon={<Trash2 size={16} />}
variant="ghost"
onClick={() => onRemove(placement.instanceId)}
/>
</div>
) : undefined
}
>
<DashboardWidgetContent
widget={widget}
placement={placement}
settings={settings}
auth={auth}
modules={modules}
effectiveView={effectiveView}
refreshKey={refreshKey}
/>
</Card>
</DashboardMasonryItem>
);
})}
{configuring && (
<div
className="dashboard-grid-end-drop-target"
onDragOver={onDragOverEnd}
onDrop={onDropAtEnd}
>
Drop here to place the widget last
</div>
)}
</div>
);
}
function DashboardDropPlaceholder({
title,
size,
columnStart,
preserveHeight = false,
onDragOver,
onDrop,
children
}: {
title: string;
size: DashboardWidgetPlacement["size"];
columnStart: number;
preserveHeight?: boolean;
onDragOver: (event: ReactDragEvent<HTMLElement>) => void;
onDrop: (event: ReactDragEvent<HTMLElement>) => void;
children?: ReactNode;
}) {
return (
<DashboardMasonryItem
className={[
"dashboard-widget",
`dashboard-widget-${size}`,
"dashboard-widget-drop-placeholder",
preserveHeight ? "preserves-widget-height" : ""
]
.filter(Boolean)
.join(" ")}
style={dashboardColumnStyle(columnStart)}
onDragOver={onDragOver}
onDrop={onDrop}
aria-label={
`Place ${title} in column ${columnStart}, spanning `
+ `${dashboardWidgetColumnSpan(size)} columns`
}
>
{children && (
<div className="dashboard-widget-placeholder-content">
{children}
</div>
)}
<div className="dashboard-widget-placeholder-surface">
<GripVertical size={18} aria-hidden="true" />
<strong>{title}</strong>
</div>
</DashboardMasonryItem>
);
}
function dashboardColumnStyle(columnStart: number): CSSProperties {
return {
"--dashboard-column-start": columnStart
} as CSSProperties;
}
function DashboardMasonryItem({
children,
...props
}: HTMLAttributes<HTMLElement>) {
const itemRef = useRef<HTMLElement>(null);
useLayoutEffect(() => {
const item = itemRef.current;
const grid = item?.parentElement;
if (!item || !grid) return undefined;
const updateSpan = () => {
const gridStyles = window.getComputedStyle(grid);
const itemStyles = window.getComputedStyle(item);
const rowHeight = Number.parseFloat(gridStyles.gridAutoRows);
const bottomGap = Number.parseFloat(itemStyles.marginBottom);
const nextSpan = dashboardMasonryRowSpan(
item.getBoundingClientRect().height,
rowHeight,
bottomGap
);
const gridRowEnd = `span ${nextSpan}`;
if (item.style.gridRowEnd !== gridRowEnd) {
item.style.gridRowEnd = gridRowEnd;
}
};
updateSpan();
if (typeof ResizeObserver === "undefined") return undefined;
const observer = new ResizeObserver(updateSpan);
observer.observe(item);
return () => observer.disconnect();
}, []);
return (
<section ref={itemRef} {...props}>
{children}
</section>
);
}
function DashboardWidgetContent({
widget,
placement,
settings,
auth,
modules,
effectiveView,
refreshKey
}: {
widget: DashboardWidgetContribution;
placement: DashboardWidgetPlacement;
settings: ApiSettings;
auth: AuthInfo;
modules: PlatformWebModule[];
effectiveView: EffectiveViewProjection | null;
refreshKey: number;
}) {
const [scheduledRefreshKey, setScheduledRefreshKey] = useState(0);
useEffect(() => {
if (!widget.refreshIntervalMs) return undefined;
const interval = window.setInterval(
() => setScheduledRefreshKey((value) => value + 1),
Math.max(widget.refreshIntervalMs, 5_000)
);
return () => window.clearInterval(interval);
}, [widget.refreshIntervalMs]);
return widget.render({
settings,
auth,
modules,
effectiveView,
instanceId: placement.instanceId,
widgetId: widget.id,
refreshKey: refreshKey + scheduledRefreshKey,
size: placement.size,
configuration: placement.configuration
});
}