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; 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, instanceId: string) => void; onDragOverEnd: (event: ReactDragEvent) => void; onDrop: ( event: ReactDragEvent, placement: DashboardWidgetPlacement ) => void; onDropPreview: (event: ReactDragEvent) => void; onDropAtEnd: (event: ReactDragEvent) => 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 (
{ if (!configuring) return; onDragOverEnd(event); }} onDrop={configuring ? onDropAtEnd : undefined} >

No widgets on this Dashboard

{configuring ? "Drag a widget here from the library." : "Open Configure to add widgets provided by active modules."}

); } 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 (
{ 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 ( event.preventDefault()} onDrop={onDropPreview} /> ); } const placement = item.placement; const widget = widgetById.get(placement.widgetId); if (!widget) return null; if (item.placeholder) { return ( event.preventDefault()} onDrop={onDropPreview} > }> ); } return ( onDragOver(event, placement.instanceId) : undefined } onDrop={ configuring ? (event) => onDrop(event, placement) : undefined } > } collapsible={!configuring} collapseKey={`dashboard-widget:${placement.instanceId}`} actions={ configuring ? (
} variant="ghost" className="dashboard-widget-drag-handle" draggable onDragStart={(event) => onDragStart(event, { kind: "placement", instanceId: placement.instanceId }) } onDragEnd={onDragEnd} /> {widgetIsConfigurable(widget) && ( } variant="ghost" onClick={() => onConfigure(placement.instanceId)} /> )} } variant="ghost" onClick={() => onRemove(placement.instanceId)} />
) : undefined } >
); })} {configuring && (
Drop here to place the widget last
)}
); } function DashboardDropPlaceholder({ title, size, columnStart, preserveHeight = false, onDragOver, onDrop, children }: { title: string; size: DashboardWidgetPlacement["size"]; columnStart: number; preserveHeight?: boolean; onDragOver: (event: ReactDragEvent) => void; onDrop: (event: ReactDragEvent) => void; children?: ReactNode; }) { return ( {children && (
{children}
)}
); } function dashboardColumnStyle(columnStart: number): CSSProperties { return { "--dashboard-column-start": columnStart } as CSSProperties; } function DashboardMasonryItem({ children, ...props }: HTMLAttributes) { const itemRef = useRef(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 (
{children}
); } 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 }); }