Files
app/frontend/src/components/Dashboard/DashboardEditor.tsx
T
stroblme b5ce0ae1c3 Keep the lock off the stacked editor while a dashboard is arranged
The phone path draws through DashboardView in both modes, so a locked
dashboard came out with dead controls while it was being arranged — the
canvas path hands editing to its own grid and never mounts the lock.
DashboardView takes `editing` and skips LockedProvider for it; reading a
locked dashboard stacked still locks. Guarded in mobile.spec.ts.
2026-08-28 19:27:55 +02:00

839 lines
29 KiB
TypeScript

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 (
<Frame
title={showTitle(widget) ? widget.title : undefined}
issue={widgetIssue(widget)}
grip={grip}
selected={selected}
onClick={(event) => {
if (!(event.target as Element).closest(INTERACTIVE)) onPick()
}}
>
<WidgetBody widget={widget} dashboard={dashboard} />
</Frame>
)
}
/** 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>(dashboard)
const [selected, setSelected] = useState<string | null>(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<ReturnType<typeof setTimeout> | 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<Promise<void> | 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<WidgetDef>) =>
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) => (
<Pickable
widget={widget}
dashboard={draft.name}
grip={grip}
selected={widget.id === selected}
onPick={() => {
setSettingsOpen(false)
setSelected(widget.id)
}}
/>
)
const body =
edit && widgets.length === 0 ? (
<p
className="text-sm text-muted-foreground"
data-testid="dashboard-empty"
>
Nothing on this dashboard yet. Add a widget from the bar below.
</p>
) : stacked ? (
// One column at the viewport's width. Edit mode still picks a widget and
// opens its settings; only the arrangement is missing.
<div
data-look={root["data-look"]}
data-touch={root["data-touch"]}
data-palette={root["data-palette"]}
className={cn(
"h-full overflow-y-auto bg-background text-foreground",
root.className,
)}
style={root.style}
>
<DashboardView
dashboard={draft}
stacked
editing={edit}
renderWidget={edit ? pickable : undefined}
/>
</div>
) : (
<CanvasSurface
dashboard={draft}
dots={edit}
rail={
railDashboards ? (
<PanelRail
dashboards={railDashboards}
current={draft.name}
// Editing one dashboard of a panel and editing its neighbour are
// the same job, so the rail keeps whichever mode this one is in.
linkFor={(name) => ({
to: "/dashboards/$name",
params: { name },
search: edit ? { edit: true } : {},
})}
/>
) : null
}
>
{(scale) =>
!edit ? (
<DashboardView dashboard={draft} rail={hasRail} />
) : (
<GridLayout
width={area.width}
layout={layout}
onLayoutChange={applyLayout}
positionStrategy={scaledStrategy(scale)}
gridConfig={{
cols: columns,
rowHeight: rowHeightOf(draft, hasRail),
margin: [GRID_GAP, GRID_GAP],
containerPadding: [0, 0],
// The canvas is the constraint: nothing may be dragged off the
// panel it is being drawn for. Shared with the warning above, so
// the constraint and what it is warned about cannot disagree.
maxRows: rows,
}}
// Only the header moves a widget, so a slider under the cursor still
// slides and a switch still flips while the dashboard is being edited.
// The header is the handle, and a chart now draws its range
// picker there — so that one control is exempt, or picking a
// window would drag the tile instead.
dragConfig={{
handle: ".widget-grip",
cancel: "[data-testid=range-picker]",
}}
resizeConfig={{ handles: ["e", "s", "se"] }}
>
{widgets.map((widget) => (
<div key={widget.id}>{pickable(widget, true)}</div>
))}
</GridLayout>
)
}
</CanvasSurface>
)
return (
<LookProvider dashboard={draft}>
{/* 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. */}
<div
className={cn(
"absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200",
panelOpen && "md:pr-[27rem]",
)}
data-testid="dashboard-canvas"
onClick={(event) => {
const target = event.target as Element
if (!event.currentTarget.contains(target)) return
if (target.closest(ON_WIDGET)) return
setSelected(null)
setSettingsOpen(false)
}}
>
<PaletteProvider dashboard={draft}>{body}</PaletteProvider>
</div>
<div
className={cn(
"pointer-events-none absolute inset-0 transition-[right] duration-200",
panelOpen && "md:right-[27rem]",
)}
>
<CanvasTitle>
<span className="truncate px-3 py-1.5 text-sm font-medium">
{draft.title || draft.name}
</span>
</CanvasTitle>
{/* 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 : <LockNotice dashboard={draft} />}
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
// Capped and wrapping, like the flow dock; see DESIGN-GUIDELINES.md
// → Responsive.
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
>
{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 ? (
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
role="status"
aria-live="polite"
data-testid="canvas-clipped"
// A sibling of the buttons, positioned against the dock, so
// it clears however many rows the dock wrapped into.
className="absolute bottom-full left-1/2 mb-3 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-2 rounded-full border border-border bg-card/80 py-1 pl-4 pr-1 shadow-e2 backdrop-blur-md"
>
<span className="truncate text-sm">
{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.`}
</span>
<Button
variant="outline"
size="sm"
className="h-11 shrink-0 md:h-8"
onClick={reflow}
data-testid="reflow-canvas"
>
Reflow
</Button>
</motion.div>
) : null}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
aria-label="Add widget"
data-testid="add-widget"
>
<Plus />
</Button>
</PopoverTrigger>
<PopoverContent align="center" className="w-56 p-2">
<p className="px-2 py-1.5 text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
Add a widget
</p>
<div className="mt-1 grid gap-0.5">
{KINDS.map((kind) => (
<button
key={kind}
type="button"
className="rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/50"
data-testid={`add-widget-${kind}`}
onClick={() => addWidget(kind)}
>
{WIDGET_LABELS[kind]}
</button>
))}
</div>
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"size-11 text-muted-foreground md:size-8",
settingsOpen && "text-primary",
)}
onClick={() => {
if (settingsOpen) {
setSettingsOpen(false)
return
}
setSelected(null)
setSettingsOpen(true)
}}
aria-pressed={settingsOpen}
aria-label="Dashboard settings"
data-testid="edit-dashboard"
>
<Settings2 />
</Button>
</TooltipTrigger>
<TooltipContent>Dashboard settings</TooltipContent>
</Tooltip>
{/* Only while there is something to throw away. */}
{hasDraft ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={() => setDiscardOpen(true)}
aria-label="Discard the unpublished changes"
data-testid="discard-dashboard"
>
<X />
</Button>
</TooltipTrigger>
<TooltipContent>
Discard the unpublished changes
</TooltipContent>
</Tooltip>
) : null}
{/* Saved state and publish are one control, as in the flow dock:
the glyph stays put and simply stops being pressable. */}
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={() => void publishDashboard()}
disabled={
!hasDraft || publish.isPending || save.isPending
}
aria-label="Publish this dashboard"
data-testid="publish-dashboard"
>
{save.isPending || publish.isPending ? (
<FluksioLoader className="size-3.5" />
) : (
<Check className="size-3.5" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{publish.isPending
? "Publishing"
: save.isPending
? "Saving"
: hasDraft
? "Saved — publish to put it on the panels"
: "All changes saved"}
</TooltipContent>
</Tooltip>
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
</>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
aria-label="Open the panel view"
data-testid="open-view"
asChild
>
<a
href={appPath(`/view/${draft.name}`)}
target="_blank"
rel="noreferrer"
>
<ExternalLink />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Open what a wall panel sees</TooltipContent>
</Tooltip>
<Button
variant={edit ? "brand" : "secondary"}
size="sm"
className="h-11 gap-1.5 md:h-8"
onClick={() => setEdit(!edit)}
data-testid="toggle-edit"
>
{edit ? (
<Check className="size-4" />
) : (
<Pencil className="size-4" />
)}
{edit ? "Done" : "Edit"}
</Button>
</motion.div>
</div>
{edit ? (
<>
<WidgetPanel
widget={active}
onChange={(changes) => active && patch(active.id, changes)}
onDelete={() => {
if (!active) return
updateWidgets(widgets.filter((other) => other.id !== active.id))
setSelected(null)
}}
onClose={() => setSelected(null)}
/>
<DashboardPanel
open={settingsOpen}
dashboard={draft}
widgetCount={widgets.length}
onChange={(changes) => commit({ ...draft, ...changes })}
onDelete={() => remove.mutate()}
onClose={() => setSettingsOpen(false)}
/>
<Dialog open={discardOpen} onOpenChange={setDiscardOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Discard the unpublished changes?</DialogTitle>
<DialogDescription>
The dashboard goes back to what the panels are showing. What
you edited since is dropped.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardOpen(false)}>
Keep editing
</Button>
<Button
variant="destructive"
disabled={discard.isPending}
onClick={() => {
setDiscardOpen(false)
// Leaving edit mode remounts the editor, which is how the
// published document replaces the working copy.
discard.mutate(undefined, {
onSuccess: () => setEdit(false),
})
}}
data-testid="confirm-discard-dashboard"
>
Discard changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
) : null}
</LookProvider>
)
}