import { useMutation, useQueryClient } from "@tanstack/react-query" import { useNavigate } from "@tanstack/react-router" import { Check, ExternalLink, Loader2, Pencil, Plus, Settings2, } from "lucide-react" import { motion } from "motion/react" import { useEffect, useRef, useState } from "react" import { GridLayout, type Layout, useContainerWidth } from "react-grid-layout" import "react-grid-layout/css/styles.css" import { type ApiError, type DashboardDef_Output, DashboardsService, type Placement, type WidgetDef, } from "@/client" import { CanvasTitle } from "@/components/Flow/CanvasTitle" import { Button } from "@/components/ui/button" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" import { Separator } from "@/components/ui/separator" import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" import useCustomToast from "@/hooks/useCustomToast" import { slideUp, transitions } from "@/lib/motion" import { cn } from "@/lib/utils" import { handleError } from "@/utils" import { columnsOf, type Dashboard, DashboardView, GRID_GAP, isPlaced, pagesOf, placement, ROW_HEIGHT, sectionsOf, widgetsOf, } from "./DashboardView" import { DashboardPanel, WidgetPanel } from "./panels" import { dashboardKeys, useSaveDashboard } from "./queries" import { WIDGET_LABELS, WIDGET_SIZES, WidgetBody, WidgetFrame, type WidgetKind, widgetIssue, } from "./widgets" const KINDS = Object.keys(WIDGET_LABELS) as WidgetKind[] /** How long to sit on edits before saving, so typing is not a save per key. */ const AUTOSAVE_MS = 800 /** * Controls a widget owns. A press on one of these is the widget's own — a * slider still slides in edit mode — so it never counts as picking the widget. */ const INTERACTIVE = "button, a, input, select, textarea, [role='switch'], [role='combobox'], [role='slider'], .react-resizable-handle" function nextId(dashboard: DashboardDef_Output, type: string): string { const taken = new Set( pagesOf(dashboard).flatMap((page) => sectionsOf(page).flatMap((section) => widgetsOf(section).map((widget) => widget.id), ), ), ) let candidate = type for (let i = 2; taken.has(candidate); i++) candidate = `${type}${i}` return candidate } /** * Positions for a dashboard nobody has arranged yet. * * Widths were the only thing the old editor set, so every widget sits at 0,0. * Shelf-packing them into the grid is what the browser was doing implicitly; * writing it down is what lets them be dragged from there. */ function packed(widgets: WidgetDef[], columns: number): Layout { let x = 0 let y = 0 let shelf = 0 return widgets.map((widget) => { const { w = 3, h = 2 } = placement(widget) const width = Math.min(columns, Math.max(1, w)) if (x + width > columns) { x = 0 y += shelf shelf = 0 } const item = { i: widget.id, x, y, w: width, h: Math.max(1, h) } x += width shelf = Math.max(shelf, item.h) return item }) } function layoutOf(widgets: WidgetDef[], columns: number): Layout { if (!isPlaced(widgets)) return packed(widgets, columns) return widgets.map((widget) => { const { x = 0, y = 0, w = 3, h = 2 } = placement(widget) const width = Math.min(columns, Math.max(1, w)) return { i: widget.id, x: Math.min(columns - width, Math.max(0, x)), y: Math.max(0, y), w: width, h: Math.max(1, h), } }) } const same = (a: Layout, b: Layout) => a.length === b.length && a.every((item, index) => { const other = b[index] return ( other && item.i === other.i && item.x === other.x && item.y === other.y && item.w === other.w && item.h === other.h ) }) /** * A dashboard, viewed or edited, over the same dotted canvas the flows use. * * View mode never mounts the grid library: a wall panel that only displays * should not pay for the code that lets someone drag things around. */ export function DashboardEditor({ dashboard, edit, }: { dashboard: Dashboard edit: boolean }) { const [draft, setDraft] = useState(dashboard) const [selected, setSelected] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) const [pageId, setPageId] = useState( () => pagesOf(dashboard)[0]?.id, ) const navigate = useNavigate() const queryClient = useQueryClient() const save = useSaveDashboard(dashboard.name) const { showErrorToast } = useCustomToast() const timer = useRef | null>(null) // The saved version is what the next save is based on; without following it // the second save of a session is always a conflict. const version = useRef(dashboard.version) useEffect(() => { version.current = dashboard.version }, [dashboard.version]) const commit = (next: Dashboard) => { setDraft(next) if (timer.current) clearTimeout(timer.current) timer.current = setTimeout(() => { save.mutate( { ...next, version: version.current }, { onSuccess: (saved) => { version.current = saved.version }, onError: (error) => handleError.call(showErrorToast, error as ApiError), }, ) }, AUTOSAVE_MS) } const remove = useMutation({ mutationFn: () => DashboardsService.deleteDashboard({ name: draft.name }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: dashboardKeys.all }) navigate({ to: "/dashboards" }) }, onError: handleError.bind(showErrorToast), }) const columns = columnsOf(draft) const pages = pagesOf(draft) const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0] const section = page ? sectionsOf(page)[0] : undefined const widgets = section ? widgetsOf(section) : [] const { width, containerRef, mounted } = useContainerWidth() const updateWidgets = (next: WidgetDef[]) => { if (!page || !section) return commit({ ...draft, pages: pagesOf(draft).map((candidate) => candidate.id !== page.id ? candidate : { ...candidate, sections: sectionsOf(candidate).map((existing) => existing.id !== section.id ? existing : { ...existing, widgets: next }, ), }, ), }) } const addWidget = (type: WidgetKind) => { const id = nextId(draft, type) const bottom = layoutOf(widgets, columns).reduce( (lowest, item) => Math.max(lowest, item.y + item.h), 0, ) updateWidgets([ ...widgets, { id, type, title: WIDGET_LABELS[type], layout: { lg: { x: 0, y: bottom, ...WIDGET_SIZES[type] } }, config: type === "chart" ? { series: [{}] } : {}, }, ]) setSettingsOpen(false) setSelected(id) } const patch = (id: string, changes: Partial) => updateWidgets( widgets.map((widget) => widget.id === id ? { ...widget, ...changes } : widget, ), ) const layout = layoutOf(widgets, columns) /** Store what the grid ended up doing, unless it did nothing. */ const applyLayout = (next: Layout) => { if (same(layout, next)) return const byId = new Map(next.map((item) => [item.i, item])) updateWidgets( widgets.map((widget) => { const item = byId.get(widget.id) if (!item) return widget const placed: Placement = { x: item.x, y: item.y, w: item.w, h: item.h, } return { ...widget, layout: { ...(widget.layout ?? {}), lg: placed }, } }), ) } const active = widgets.find((widget) => widget.id === selected) ?? null const panelOpen = Boolean(active) || settingsOpen const setEdit = (next: boolean) => { setSelected(null) setSettingsOpen(false) navigate({ to: "/dashboards/$name", params: { name: draft.name }, search: next ? { edit: true } : {}, }) } const body = !page ? (

This dashboard has no pages yet.

) : !edit ? ( ) : widgets.length === 0 ? (

Nothing on this page yet. Add a widget from the bar below.

) : (
{/* Measured first: laying out against a guessed width would place every widget once and then move it. */} {mounted ? ( {widgets.map((widget) => (
{ if (!(event.target as Element).closest(INTERACTIVE)) { setSettingsOpen(false) setSelected(widget.id) } }} >
))}
) : null}
) return ( <>
{body}
{draft.title || draft.name} {pages.length > 1 ? ( {pages.map((candidate) => ( {candidate.title || candidate.id} ))} ) : null} {edit ? ( <>

Add a widget

{KINDS.map((kind) => ( ))}
Dashboard settings {save.isPending ? ( ) : ( )} {save.isPending ? "Saving" : "All changes saved"} ) : null} Open what a wall panel sees
{edit ? ( <> active && patch(active.id, changes)} onDelete={() => { if (!active) return updateWidgets(widgets.filter((other) => other.id !== active.id)) setSelected(null) }} onClose={() => setSelected(null)} /> commit({ ...draft, ...changes })} onDelete={() => remove.mutate()} onClose={() => setSettingsOpen(false)} /> ) : null} ) }