import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useNavigate } from "@tanstack/react-router" import { Check, ExternalLink, Pencil, Plus, Settings2, X } from "lucide-react" import { motion } from "motion/react" import { type CSSProperties, useEffect, useRef, useState } from "react" import { GridLayout, type Layout, type Position, setTransform, } 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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { FluksioLoader } from "@/components/ui/fluksio-loader" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" import { Separator } from "@/components/ui/separator" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" import useCustomToast from "@/hooks/useCustomToast" import { useIsMobile } from "@/hooks/useMobile" import { slideUp, transitions } from "@/lib/motion" import { appPath } from "@/lib/portal" import { cn } from "@/lib/utils" import { handleError } from "@/utils" import { areaOf, CanvasSurface, columnsOf, type Dashboard, DashboardView, GRID_GAP, isPlaced, placement, rowHeightOf, rowsOf, widgetsOf, } from "./DashboardView" import { PanelRail } from "./PanelRail" import { LockNotice } from "./PanelSurface" import { DashboardPanel, WidgetPanel } from "./panels" import { dashboardKeys, panelsQueryOptions, useDiscardDashboardDraft, usePublishDashboard, useSaveDashboard, } from "./queries" import { PaletteProvider } from "./settings" import { useUi } from "./ui" import { showTitle } from "./ui/core/config" import { LookProvider, useCanvasRoot } from "./ui/core/look" import { WIDGET_LABELS, WIDGET_SIZES, WidgetBody, type WidgetKind, widgetIssue, } from "./widgets" const KINDS = Object.keys(WIDGET_LABELS) as WidgetKind[] /** * A widget as the editor draws it: the panel's own look, plus a click that * picks it. * * Its own component because it draws with the look's components, which are * only knowable below `LookProvider`. */ function Pickable({ widget, dashboard, grip, selected, onPick, }: { widget: WidgetDef dashboard: string grip: boolean selected: boolean onPick: () => void }) { const { Frame } = useUi() return ( { if (!(event.target as Element).closest(INTERACTIVE)) onPick() }} > ) } /** 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. * * The drag handle counts too: a press on the grip is a move, not a pick, and * the mouseup ending a drag still fires a click on the frame. Selecting there * would open the settings panel — and the canvas lurches while it animates — * every time a widget is dropped. The body is what picks it instead, which is * how the resize handle has always behaved. On a phone the grip class is never * applied, so the header keeps selecting: there is no dragging to confuse it * with. */ const INTERACTIVE = "button, a, input, select, textarea, [role='switch'], [role='combobox'], [role='slider'], .react-resizable-handle, .widget-grip" /** * What a click may land on without counting as a click off the widgets: the * widget itself, and the handles the grid draws around it — resizing a tile is * still working on it. The grid item covers both; the frame is what a stacked * dashboard has instead. */ const ON_WIDGET = "[data-testid=widget-frame], .react-grid-item" function nextId(dashboard: DashboardDef_Output, type: string): string { const taken = new Set(widgetsOf(dashboard).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), } }) } /** * How the grid positions and drags items on a CSS-scaled surface. * * The library's own `createScaledStrategy` divides the drag delta by the scale * but drops the container offset while doing it, so a widget jumps on pick-up. * Leaving `calcDragPosition` unset keeps its parent-relative maths, which * already divides by `scale` — the only thing that needed saying. */ const scaledStrategy = (scale: number) => ({ type: "transform" as const, scale, calcStyle: setTransform as (pos: Position) => CSSProperties, }) 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. Nor does a * phone — arranging is not a phone feature, and `applyLayout` below writes * whatever the grid reports, so a stacked layout reaching it would overwrite * the arrangement the panel is meant to show. */ export function DashboardEditor({ dashboard, edit, }: { dashboard: Dashboard edit: boolean }) { const [draft, setDraft] = useState(dashboard) const [selected, setSelected] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) // A phone reads the dashboard rather than arranges it, so the grid library // never mounts there. See DESIGN-GUIDELINES.md → Responsive. const stacked = useIsMobile() // The dashboard's own appearance, on the surface only: the shell around the // canvas stays whatever the person editing chose for the app. Non-stacked, // `CanvasSurface` states it on the canvas box itself. const root = useCanvasRoot(draft) const navigate = useNavigate() const queryClient = useQueryClient() const save = useSaveDashboard(dashboard.name) const publish = usePublishDashboard(dashboard.name) const discard = useDiscardDashboardDraft(dashboard.name) const [discardOpen, setDiscardOpen] = useState(false) const { showErrorToast } = useCustomToast() const timer = useRef | null>(null) // The save that is already on its way, so a publish waits for it instead of // going out with the version it is about to replace. const inflight = 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 store = (next: Dashboard) => { const request = save .mutateAsync({ ...next, version: version.current }) .then((saved) => { version.current = saved.version }) .catch((error) => handleError.call(showErrorToast, error as ApiError)) inflight.current = request return request } const commit = (next: Dashboard) => { setDraft(next) if (timer.current) clearTimeout(timer.current) timer.current = setTimeout(() => { timer.current = null void store(next) }, AUTOSAVE_MS) } /** Send what is queued and wait for the server to have it. */ const flush = async () => { if (timer.current) { clearTimeout(timer.current) timer.current = null await store(draft) return } await inflight.current } /** Put the stored draft live. Publishing what is queued means saving first. */ const publishDashboard = async () => { await flush() publish.mutate(version.current ?? 1) } const remove = useMutation({ mutationFn: () => DashboardsService.deleteDashboard({ name: draft.name }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: dashboardKeys.all }) navigate({ to: "/dashboards" }) }, onError: handleError.bind(showErrorToast), }) // What the server says about the stored document, not about the local edit: // a save in flight is still "no unpublished changes" until it lands. const hasDraft = Boolean(dashboard.has_draft) const columns = columnsOf(draft) const widgets = widgetsOf(draft) const updateWidgets = (next: WidgetDef[]) => { commit({ ...draft, 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, ), ) // A dashboard hanging on a panel beside others is drawn with a rail on it, // which takes room off the canvas. Show that here rather than letting someone // arrange against a width the wall does not have. // ponytail: the first such panel wins — the others differ only in scale. const { data: panels } = useQuery(panelsQueryOptions()) const railDashboards = (panels?.panels ?? []).find( (panel) => (panel.dashboards ?? []).length > 1 && (panel.dashboards ?? []).includes(draft.name), )?.dashboards ?? null const hasRail = Boolean(railDashboards) && !stacked // What the arrangement is laid out in: the canvas less its margin, and less // the rail's column when the panel carries one. const area = areaOf(draft, hasRail) const layout = layoutOf(widgets, columns) const rows = rowsOf(draft, hasRail) // maxRows below only constrains a *drag*. A stored placement is corrected // against `cols` and nothing else, so a canvas that shrank leaves whatever // now falls past its bottom edge drawn under the clip. const clipped = layout.filter((item) => item.y + item.h > rows) /** 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 }, } }), ) } /** Shelf-pack in reading order, which is the only reflow that can help: * the grid already compacts vertically, so nothing below the canvas has * room above it — it has to move sideways. */ const reflow = () => { const at = new Map(layout.map((item) => [item.i, item])) const ordered = [...widgets].sort((a, b) => { const left = at.get(a.id) const right = at.get(b.id) return ( (left?.y ?? 0) - (right?.y ?? 0) || (left?.x ?? 0) - (right?.x ?? 0) ) }) applyLayout(packed(ordered, columns)) } 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 } : {}, }) } /** A widget that can be picked to open its settings. */ const pickable = (widget: WidgetDef, grip = false) => ( { setSettingsOpen(false) setSelected(widget.id) }} /> ) const body = edit && widgets.length === 0 ? (

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

) : stacked ? ( // One column at the viewport's width. Edit mode still picks a widget and // opens its settings; only the arrangement is missing.
) : ( ({ to: "/dashboards/$name", params: { name }, search: edit ? { edit: true } : {}, })} /> ) : null } > {(scale) => !edit ? ( ) : ( {widgets.map((widget) => (
{pickable(widget, true)}
))}
) }
) return ( {/* Clicking off the widgets is how you put a panel away, as it is on the flow pane — the dashboard is what you went back to look at. A menu a widget portals to `body` lands outside this element but still bubbles through React's tree, so only a press that landed in the subtree counts. */} {/* biome-ignore lint/a11y/useKeyWithClickEvents: Escape closes the panel; this is a pointer shortcut, not a control. */} {/* biome-ignore lint/a11y/noStaticElementInteractions: see above. */}
{ const target = event.target as Element if (!event.currentTarget.contains(target)) return if (target.closest(ON_WIDGET)) return setSelected(null) setSettingsOpen(false) }} > {body}
{draft.title || draft.name} {/* Reading this dashboard is reading the panel, disabled controls and all — so it says the same thing a panel does. Not while editing: arranging never mounts the lock, so the controls are live. */} {edit ? null : } {edit ? ( <> {/* A canvas can be shrunk to less than what is arranged on it, and the grid says nothing: the placement it corrects is bounded by the columns alone. Say so, and offer the one remedy — re-packing across the columns. Not an error, so no destructive colour and no brand fill either; Done already owns the one brand affordance here. A phone has no canvas to fall off, so it never sees this. */} {clipped.length > 0 && !stacked ? ( {clipped.length === 1 ? "1 widget sits below the canvas — a panel this size will not show it." : `${clipped.length} widgets sit below the canvas — a panel this size will not show them.`} ) : null}

Add a widget

{KINDS.map((kind) => ( ))}
Dashboard settings {/* Only while there is something to throw away. */} {hasDraft ? ( Discard the unpublished changes ) : null} {/* Saved state and publish are one control, as in the flow dock: the glyph stays put and simply stops being pressable. */} {publish.isPending ? "Publishing" : save.isPending ? "Saving" : hasDraft ? "Saved — publish to put it on the panels" : "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)} /> Discard the unpublished changes? The dashboard goes back to what the panels are showing. What you edited since is dropped. ) : null}
) }