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.
389 lines
11 KiB
TypeScript
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
|
|
});
|
|
}
|