Rework the dashboards onto the flow canvas

One shell for both editors. Flows and dashboards each get a searchable
overview under the padded shell, their editors move to the full-bleed
canvas, and the floating chrome is shared: a title bar that only says
what you are looking at, and a bottom dock carrying everything else —
the flow bar's status, settings and Publish moved down there, the
add-flow button moved to the overview.

Dashboards gain the rest of M4's visualization work:

- widgets are picked by clicking them, with the header as the drag
  handle so a slider still slides and a switch still flips while
  editing; settings moved into the flows' SidePanel
- react-grid-layout for drag and edge-resize, so the stored x/y finally
  mean something; a dashboard nobody arranged is shelf-packed once
- a per-dashboard grid size, so a panel can be matched to its screen
- the chart widget, drawn with uPlot: several messages on one axis, fed
  from the stored history plus the live socket tail, coloured from the
  new --chart-1..5 ramp
- /view/{name}: the URL a wall panel is pointed at — no sidebar, no
  footer, no editing, and no editor code, since routes are split
- a widget wired to a payload type it cannot carry, or wired to nothing
  at all, carries the same red dot a failing node does; the picker
  records the type it bound and WidgetDef refuses a mismatch on save

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
2026-08-16 17:13:10 +02:00
co-authored by Claude Fable 5
parent 74bb956805
commit 0af09eedbe
24 changed files with 1799 additions and 1558 deletions
+2
View File
@@ -49,11 +49,13 @@
"react": "^19.1.1",
"react-dom": "^19.2.3",
"react-error-boundary": "^6.0.0",
"react-grid-layout": "^2.2.4",
"react-hook-form": "^7.68.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"uplot": "^1.6.32",
"zod": "^4.3.6"
},
"devDependencies": {
+8 -5
View File
@@ -90,6 +90,7 @@ async function captureDashboards(page, dir) {
await page.getByTestId("create-dashboard").click()
await page.waitForURL(/\/dashboards\/.+/, { timeout: 15000 })
// A widget, so the grid has something in it worth photographing.
await page.getByTestId("add-widget").click()
await page.getByTestId("add-widget-stat").click()
await page.waitForSelector("[data-testid=widget-settings]")
await page.getByTestId("toggle-edit").click()
@@ -109,11 +110,13 @@ async function captureDashboards(page, dir) {
async function captureFlows(page, dir) {
await page.goto(`${APP_URL}/flows`, { waitUntil: "networkidle" })
const seed = page.getByTestId("create-first-flow")
if (await seed.count()) {
await seed.click()
await page.waitForURL(/\/flows\/.+/, { timeout: 15000 })
if (await page.getByTestId("flow-card").count()) {
await page.getByTestId("flow-card").first().click()
} else {
await page.getByTestId("new-flow-name").fill("first_flow")
await page.getByTestId("create-flow").click()
}
await page.waitForURL(/\/flows\/.+/, { timeout: 15000 })
if (!(await page.locator(".react-flow__node").count())) {
await page.getByTestId("add-node").click()
@@ -126,7 +129,7 @@ async function captureFlows(page, dir) {
// Adding a node opens its panel; the canvas shot wants it out of the way.
await page.keyboard.press("Escape")
// The dock and tabs slide in; let them land before the shutter.
// The dock and the title bar slide in; let them land before the shutter.
await page.waitForTimeout(1200)
await page.screenshot({ path: `${dir}/app-flows.png` })
Binary file not shown.
@@ -1,45 +1,78 @@
import { useQuery } from "@tanstack/react-query"
import { ChevronLeft, ChevronRight, Plus, X } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import type { ApiError, DashboardDef_Output, WidgetDef } from "@/client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
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,
SectionGrid,
placement,
ROW_HEIGHT,
sectionsOf,
widgetStyle,
widgetsOf,
} from "./DashboardView"
import { messageCatalogQueryOptions, useSaveDashboard } from "./queries"
import { DashboardPanel, WidgetPanel } from "./panels"
import { dashboardKeys, useSaveDashboard } from "./queries"
import {
INPUT_WIDGETS,
WIDGET_LABELS,
WIDGET_SIZES,
WidgetBody,
WidgetFrame,
type WidgetKind,
widgetIssue,
} from "./widgets"
// Charts are stored and validated, but not drawn yet, so they are not offered.
const KINDS = (Object.keys(WIDGET_LABELS) as WidgetKind[]).filter(
(kind) => kind !== "chart",
)
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) =>
@@ -53,15 +86,82 @@ function nextId(dashboard: DashboardDef_Output, type: string): string {
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,
pageId,
edit,
}: {
dashboard: DashboardDef_Output
pageId?: string
dashboard: Dashboard
edit: boolean
}) {
const [draft, setDraft] = useState<DashboardDef_Output>(dashboard)
const [draft, setDraft] = useState<Dashboard>(dashboard)
const [selected, setSelected] = useState<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [pageId, setPageId] = useState<string | undefined>(
() => pagesOf(dashboard)[0]?.id,
)
const navigate = useNavigate()
const queryClient = useQueryClient()
const save = useSaveDashboard(dashboard.name)
const { showErrorToast } = useCustomToast()
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -73,7 +173,7 @@ export function DashboardEditor({
version.current = dashboard.version
}, [dashboard.version])
const commit = (next: DashboardDef_Output) => {
const commit = (next: Dashboard) => {
setDraft(next)
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => {
@@ -90,14 +190,24 @@ export function DashboardEditor({
}, 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]
if (!page) return null
const section = sectionsOf(page)[0]
if (!section) return null
const widgets = widgetsOf(section)
const section = page ? sectionsOf(page)[0] : undefined
const widgets = section ? widgetsOf(section) : []
const { width, containerRef, mounted } = useContainerWidth()
const updateWidgets = (next: WidgetDef[]) =>
const updateWidgets = (next: WidgetDef[]) => {
if (!page || !section) return
commit({
...draft,
pages: pagesOf(draft).map((candidate) =>
@@ -113,19 +223,25 @@ export function DashboardEditor({
},
),
})
}
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: 0, ...WIDGET_SIZES[type] } },
config: {},
layout: { lg: { x: 0, y: bottom, ...WIDGET_SIZES[type] } },
config: type === "chart" ? { series: [{}] } : {},
},
])
setSettingsOpen(false)
setSelected(id)
}
@@ -136,238 +252,276 @@ export function DashboardEditor({
),
)
const resize = (widget: WidgetDef, by: number) => {
const layout = (widget.layout ?? {}) as Record<
string,
{ x?: number; y?: number; w?: number; h?: number }
>
const current = layout.lg ?? { x: 0, y: 0, w: 3, h: 2 }
patch(widget.id, {
layout: {
...layout,
lg: { ...current, w: Math.min(12, Math.max(2, (current.w ?? 3) + by)) },
},
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 active = widgets.find((widget) => widget.id === selected)
return (
<div className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Add</span>
{KINDS.map((kind) => (
<Button
key={kind}
variant="ghost"
size="sm"
className="h-8 text-xs"
data-testid={`add-widget-${kind}`}
onClick={() => addWidget(kind)}
>
<Plus />
{WIDGET_LABELS[kind]}
</Button>
))}
</div>
<SectionGrid
section={section}
dashboard={draft.name}
renderWidget={(widget) => (
<WidgetFrame
title={widget.title}
className={
widget.id === selected ? "ring-2 ring-primary" : undefined
}
actions={
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Narrower"
onClick={() => resize(widget, -1)}
>
<ChevronLeft />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Wider"
onClick={() => resize(widget, 1)}
>
<ChevronRight />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove widget"
onClick={() =>
updateWidgets(
widgets.filter((other) => other.id !== widget.id),
)
const body = !page ? (
<p className="text-sm text-muted-foreground">
This dashboard has no pages yet.
</p>
) : !edit ? (
<DashboardView dashboard={draft} pageId={page.id} />
) : widgets.length === 0 ? (
<p className="text-sm text-muted-foreground" data-testid="dashboard-empty">
Nothing on this page yet. Add a widget from the bar below.
</p>
) : (
<div ref={containerRef}>
{/* Measured first: laying out against a guessed width would place every
widget once and then move it. */}
{mounted ? (
<GridLayout
width={width}
layout={layout}
onLayoutChange={applyLayout}
gridConfig={{
cols: columns,
rowHeight: ROW_HEIGHT,
margin: [GRID_GAP, GRID_GAP],
containerPadding: [0, 0],
}}
// 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.
dragConfig={{ handle: ".widget-grip" }}
resizeConfig={{ handles: ["e", "s", "se"] }}
>
{widgets.map((widget) => (
<div key={widget.id}>
<WidgetFrame
title={widget.title}
issue={widgetIssue(widget)}
className={cn(
"cursor-pointer",
widget.id === selected && "ring-2 ring-primary",
)}
grip
onClick={(event) => {
if (!(event.target as Element).closest(INTERACTIVE)) {
setSettingsOpen(false)
setSelected(widget.id)
}
>
<X />
</Button>
</div>
}
>
<button
type="button"
className="min-h-0 flex-1 text-left"
onClick={() => setSelected(widget.id)}
>
<WidgetBody widget={widget} dashboard={draft.name} />
</button>
</WidgetFrame>
)}
/>
{active ? (
<WidgetSettings
widget={active}
onChange={(changes) => patch(active.id, changes)}
/>
) : (
<p className="text-sm text-muted-foreground">
Add a widget, or pick one to configure it.
</p>
)}
}}
>
<WidgetBody widget={widget} dashboard={draft.name} />
</WidgetFrame>
</div>
))}
</GridLayout>
) : null}
</div>
)
}
/** What one widget shows or does. Fields differ per type; the set is small. */
function WidgetSettings({
widget,
onChange,
}: {
widget: WidgetDef
onChange: (changes: Partial<WidgetDef>) => void
}) {
const { data: catalog } = useQuery(messageCatalogQueryOptions())
const messages = catalog?.data ?? []
const config = (widget.config ?? {}) as Record<string, unknown>
const isInput = INPUT_WIDGETS.has(widget.type)
const numericOnly = widget.type === "gauge" || widget.type === "chart"
const set = (key: string, value: unknown) =>
onChange({ config: { ...config, [key]: value } })
const choices = messages.filter((message) =>
numericOnly ? message.numeric : true,
)
return (
<div
className="grid gap-3 rounded-lg border border-border bg-card p-4 shadow-e1"
data-testid="widget-settings"
>
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{WIDGET_LABELS[widget.type]} settings
</span>
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Title</Label>
<Input
value={widget.title ?? ""}
onChange={(event) => onChange({ title: event.target.value })}
/>
<>
<div
className={cn(
"dot-canvas absolute inset-0 overflow-y-auto px-4 pb-24 pt-20 transition-[padding] duration-200",
panelOpen && "md:pr-[27rem]",
)}
data-testid="dashboard-canvas"
>
{body}
</div>
{widget.type === "markdown" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Text</Label>
<Input
value={String(config.content ?? "")}
placeholder="# Heading"
onChange={(event) => set("content", event.target.value)}
/>
</div>
) : (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">
{isInput ? "Publishes to" : "Shows"}
</Label>
<Select
value={String(config[isInput ? "target" : "message"] ?? "")}
onValueChange={(value) =>
set(isInput ? "target" : "message", value)
}
<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>
{pages.length > 1 ? (
<Tabs value={page?.id} onValueChange={setPageId}>
<TabsList>
{pages.map((candidate) => (
<TabsTrigger key={candidate.id} value={candidate.id}>
{candidate.title || candidate.id}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
</CanvasTitle>
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-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 ? (
<>
<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="size-11 text-muted-foreground md:size-8"
onClick={() => {
setSelected(null)
setSettingsOpen(true)
}}
aria-label="Dashboard settings"
data-testid="edit-dashboard"
>
<Settings2 />
</Button>
</TooltipTrigger>
<TooltipContent>Dashboard settings</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
{save.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
</span>
</TooltipTrigger>
<TooltipContent>
{save.isPending ? "Saving" : "All changes saved"}
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 !h-5" />
</>
) : 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={`/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"
>
<SelectTrigger data-testid="widget-message">
<SelectValue placeholder="Pick a message" />
</SelectTrigger>
<SelectContent>
{choices.map((message) => (
<SelectItem key={message.name} value={message.name}>
{message.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{edit ? (
<Check className="size-4" />
) : (
<Pencil className="size-4" />
)}
{edit ? "Done" : "Edit"}
</Button>
</motion.div>
</div>
{(widget.type === "stat" || widget.type === "gauge") && (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Unit</Label>
<Input
value={String(config.unit ?? "")}
placeholder="°C"
onChange={(event) => set("unit", event.target.value)}
/>
</div>
)}
{(widget.type === "gauge" || widget.type === "slider") && (
<div className="flex gap-2">
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Minimum</Label>
<Input
type="number"
value={String(config.min ?? 0)}
onChange={(event) => set("min", Number(event.target.value) || 0)}
/>
</div>
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Maximum</Label>
<Input
type="number"
value={String(config.max ?? 100)}
onChange={(event) => set("max", Number(event.target.value) || 0)}
/>
</div>
</div>
)}
{widget.type === "button" && (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Sends</Label>
<Input
value={String(config.value ?? "")}
placeholder="true"
onChange={(event) => {
const raw = event.target.value
const asNumber = Number(raw)
set(
"value",
raw === "true" || raw === "false"
? raw === "true"
: raw !== "" && Number.isFinite(asNumber)
? asNumber
: raw,
)
{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)}
/>
</div>
)}
</div>
<DashboardPanel
open={settingsOpen}
dashboard={draft}
widgetCount={widgets.length}
onChange={(changes) => commit({ ...draft, ...changes })}
onDelete={() => remove.mutate()}
onClose={() => setSettingsOpen(false)}
/>
</>
) : null}
</>
)
}
export { widgetStyle }
@@ -1,20 +1,33 @@
import type {
DashboardDef_Output,
PageDef_Output,
Placement,
SectionDef_Output,
WidgetDef,
} from "@/client"
import { cn } from "@/lib/utils"
import { WidgetBody, WidgetFrame } from "./widgets"
import "./dashboard.css"
import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets"
/**
* Columns per breakpoint. A wall panel gets the full twelve, a phone gets
* three, which is what makes a stat tile still readable at arm's length.
* A dashboard, plus the settings the generated client does not carry yet.
*
* `columns` is stored by the backend but the SDK is regenerated on its own
* schedule, so it is widened here rather than waited for.
*/
const GRID = "grid-cols-3 md:grid-cols-6 lg:grid-cols-12"
export type Dashboard = DashboardDef_Output & { columns?: number }
/** One grid row, in pixels. Widget heights are multiples of this. */
const ROW = "5rem"
/** How many columns a wall panel is cut into, if the document does not say. */
export const DEFAULT_COLUMNS = 12
/** What a grid size setting may be set to; a panel is matched to one of these. */
export const COLUMN_CHOICES = [6, 8, 12, 16, 24]
/** One grid row, in pixels — the unit widget heights are multiples of. */
export const ROW_HEIGHT = 80
/** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */
export const GRID_GAP = 12
/**
* The generated client marks every list optional, because the server fills
@@ -24,40 +37,61 @@ export const pagesOf = (dashboard: DashboardDef_Output) => dashboard.pages ?? []
export const sectionsOf = (page: PageDef_Output) => page.sections ?? []
export const widgetsOf = (section: SectionDef_Output) => section.widgets ?? []
function placement(widget: WidgetDef) {
const layout = (widget.layout ?? {}) as Record<
string,
{ x?: number; y?: number; w?: number; h?: number }
>
export const columnsOf = (dashboard: Dashboard) =>
dashboard.columns || DEFAULT_COLUMNS
export function placement(widget: WidgetDef): Placement {
const layout = (widget.layout ?? {}) as Record<string, Placement>
return layout.lg ?? layout.md ?? layout.sm ?? {}
}
/**
* A widget's box, as plain CSS grid.
*
* View mode never loads a grid library: a wall panel that only displays
* should not pay for the code that lets someone drag things around.
* View mode never loads a grid library: a wall panel that only displays should
* not pay for the code that lets someone drag things around.
*/
export function widgetStyle(widget: WidgetDef): React.CSSProperties {
const { w = 3, h = 2 } = placement(widget)
export function widgetStyle(
widget: WidgetDef,
columns = DEFAULT_COLUMNS,
): React.CSSProperties {
const { x = 0, y = 0, w = 3, h = 2 } = placement(widget)
const width = Math.min(columns, Math.max(1, w))
return {
gridColumn: `span ${Math.min(12, Math.max(1, w))}`,
gridRow: `span ${Math.max(1, h)}`,
}
"--x": Math.min(columns - width, Math.max(0, x)) + 1,
"--y": Math.max(0, y) + 1,
"--w": width,
"--h": Math.max(1, h),
} as React.CSSProperties
}
/**
* Has anyone actually arranged this dashboard?
*
* Before drag-and-drop every widget was written at 0,0, so honouring the
* stored position would pile the whole page onto one cell.
*/
export const isPlaced = (widgets: WidgetDef[]) =>
widgets.some((widget) => {
const { x = 0, y = 0 } = placement(widget)
return x > 0 || y > 0
})
export function SectionGrid({
section,
dashboard,
columns = DEFAULT_COLUMNS,
renderWidget,
className,
}: {
section: SectionDef_Output
/** Which dashboard this is, so an input widget can name itself. */
dashboard: string
columns?: number
renderWidget?: (widget: WidgetDef) => React.ReactNode
className?: string
}) {
const widgets = widgetsOf(section)
return (
<section className="grid gap-3">
{section.title ? (
@@ -66,15 +100,20 @@ export function SectionGrid({
</h2>
) : null}
<div
className={cn("grid gap-3", GRID, className)}
style={{ gridAutoRows: ROW }}
className={cn("widget-grid", className)}
data-placed={isPlaced(widgets) || undefined}
style={{ "--widget-cols": columns } as React.CSSProperties}
>
{widgetsOf(section).map((widget) => (
<div key={widget.id} style={widgetStyle(widget)} className="min-w-0">
{widgets.map((widget) => (
<div
key={widget.id}
style={widgetStyle(widget, columns)}
className="widget-cell"
>
{renderWidget ? (
renderWidget(widget)
) : (
<WidgetFrame title={widget.title}>
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
<WidgetBody widget={widget} dashboard={dashboard} />
</WidgetFrame>
)}
@@ -90,7 +129,7 @@ export function DashboardView({
pageId,
renderWidget,
}: {
dashboard: DashboardDef_Output
dashboard: Dashboard
pageId?: string
renderWidget?: (widget: WidgetDef) => React.ReactNode
}) {
@@ -126,6 +165,7 @@ export function DashboardView({
key={section.id}
section={section}
dashboard={dashboard.name}
columns={columnsOf(dashboard)}
renderWidget={renderWidget}
/>
))}
@@ -0,0 +1,102 @@
/*
* Dashboard surface, routed through the design tokens.
*
* Scoped like `Flow/flow.css`: the grid maths and the react-grid-layout
* overrides live beside the components that use them rather than in index.css,
* so nothing here touches the byte-identical token blocks.
*/
/* The same dot grid the flow viewport paints, as plain CSS: a dashboard has no
zoom, so it needs none of React Flow's rescaling. */
.dot-canvas {
background-color: var(--background);
background-image: radial-gradient(
circle at 1px 1px,
color-mix(in srgb, var(--muted-foreground) 30%, transparent) 1.5px,
transparent 0
);
background-size: 24px 24px;
}
/*
* View mode's grid. Column count is per dashboard (`--widget-cols`), so a wall
* panel can be matched to its own width. Below the large breakpoint the stored
* placement is meaningless — three columns cannot hold a twelve-column
* arrangement — so widgets stack full width and keep only their height.
*/
.widget-grid {
display: grid;
gap: 0.75rem;
grid-auto-rows: 5rem;
grid-template-columns: minmax(0, 1fr);
}
.widget-cell {
grid-row: span var(--h);
min-width: 0;
}
@media (min-width: 64rem) {
.widget-grid {
grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr));
}
.widget-cell {
grid-column: span var(--w);
}
/* Only once something has actually been placed; an untouched dashboard has
every widget at 0,0 and would pile up on one cell. */
.widget-grid[data-placed] .widget-cell {
grid-column: var(--x) / span var(--w);
grid-row: var(--y) / span var(--h);
}
}
/*
* uPlot, routed through the tokens. Its own legend is the hover readout as
* well — the value each line carried at the cursor — so it is styled as chart
* furniture rather than replaced.
*/
.u-legend {
font-size: 0.75rem;
color: var(--muted-foreground);
margin-top: 0.25rem;
}
.u-legend .u-marker {
width: 0.5rem;
height: 0.5rem;
border-width: 2px;
border-radius: 9999px;
}
.u-legend .u-value {
font-variant-numeric: tabular-nums;
color: var(--foreground);
}
.u-legend .u-series.u-off {
opacity: 0.4;
}
.u-cursor-x,
.u-cursor-y {
border-color: color-mix(in srgb, var(--muted-foreground) 55%, transparent);
}
.u-select {
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
/* react-grid-layout ships a red placeholder and a black handle glyph. */
.react-grid-item.react-grid-placeholder {
background: var(--primary);
border-radius: var(--radius-lg);
opacity: 0.12;
}
.react-grid-item > .react-resizable-handle::after {
border-right-color: var(--muted-foreground);
border-bottom-color: var(--muted-foreground);
}
@@ -0,0 +1,436 @@
import { useQuery } from "@tanstack/react-query"
import { Plus, X } from "lucide-react"
import { useState } from "react"
import type { MessageInfo, WidgetDef } from "@/client"
import {
PANEL_SECTION,
PanelTitle,
SidePanel,
} from "@/components/Flow/SidePanel"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { MAX_SERIES } from "./ChartWidget"
import { COLUMN_CHOICES, columnsOf, type Dashboard } from "./DashboardView"
import { messageCatalogQueryOptions } from "./queries"
import {
acceptsDtype,
INPUT_WIDGETS,
type Series,
seriesOf,
WIDGET_LABELS,
type WidgetKind,
widgetIssue,
} from "./widgets"
const config = (widget: WidgetDef) =>
(widget.config ?? {}) as Record<string, unknown>
const str = (value: unknown) => (value == null ? "" : String(value))
/** Which messages this kind of widget may be pointed at. */
function choicesFor(kind: WidgetKind, catalog: MessageInfo[]): MessageInfo[] {
const input = INPUT_WIDGETS.has(kind)
return catalog.filter(
(message) =>
acceptsDtype(kind, message.dtype) &&
// Flows own the namespace; a control can only set what one declares.
(!input || message.writable !== false),
)
}
/** The picker, which records the payload type it bound along with the name. */
function MessagePicker({
kind,
value,
label,
testId,
onPick,
}: {
kind: WidgetKind
value: string
label: string
testId?: string
onPick: (message: string, dtype: string) => void
}) {
const { data } = useQuery(messageCatalogQueryOptions())
const choices = choicesFor(kind, data?.data ?? [])
return (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">{label}</Label>
<Select
value={value}
onValueChange={(next) =>
onPick(
next,
choices.find((message) => message.name === next)?.dtype ?? "",
)
}
>
<SelectTrigger data-testid={testId}>
<SelectValue placeholder="Pick a message" />
</SelectTrigger>
<SelectContent>
{choices.map((message) => (
<SelectItem key={message.name} value={message.name}>
{message.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
/**
* What one widget shows or does.
*
* The same floating panel the flow editor uses for a node, so the two editors
* read as one surface.
*/
export function WidgetPanel({
widget,
onChange,
onDelete,
onClose,
}: {
widget: WidgetDef | null
onChange: (changes: Partial<WidgetDef>) => void
onDelete: () => void
onClose: () => void
}) {
if (!widget) return null
const cfg = config(widget)
const isInput = INPUT_WIDGETS.has(widget.type)
const issue = widgetIssue(widget)
const set = (changes: Record<string, unknown>) =>
onChange({ config: { ...cfg, ...changes } })
const series = seriesOf(widget)
const setSeries = (next: Series[]) => set({ series: next })
return (
<SidePanel
open={Boolean(widget)}
label="Widget settings"
testId="widget-panel"
bodyKey={widget.id}
onClose={onClose}
header={
<PanelTitle
value={widget.title ?? ""}
placeholder={WIDGET_LABELS[widget.type]}
label="Widget title"
onConfirm={(title) => onChange({ title })}
/>
}
footer={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={onDelete}
data-testid="delete-widget"
>
Remove widget
</Button>
}
>
<div className="grid gap-5 p-4" data-testid="widget-settings">
<div className="grid gap-2">
<span className={PANEL_SECTION}>{WIDGET_LABELS[widget.type]}</span>
{issue ? (
<p className="text-sm text-destructive" data-testid="widget-error">
{issue}
</p>
) : null}
{widget.type === "markdown" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Text</Label>
<Input
value={str(cfg.content)}
placeholder="# Heading"
onChange={(event) => set({ content: event.target.value })}
/>
</div>
) : widget.type === "chart" ? (
<div className="grid gap-2">
{series.map((entry, index) => (
<div
// Position is the only identity a series row has.
key={`series-${index}`}
className="flex items-end gap-1.5"
>
<div className="min-w-0 flex-1">
<MessagePicker
kind="chart"
value={entry.message ?? ""}
label={index === 0 ? "Draws" : ""}
testId={index === 0 ? "widget-message" : undefined}
onPick={(message, dtype) =>
setSeries(
series.map((other, at) =>
at === index ? { ...other, message, dtype } : other,
),
)
}
/>
</div>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove series"
onClick={() =>
setSeries(series.filter((_, at) => at !== index))
}
>
<X />
</Button>
</div>
))}
{series.length < MAX_SERIES ? (
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
onClick={() => setSeries([...series, {}])}
data-testid="add-series"
>
<Plus />
Add series
</Button>
) : null}
</div>
) : (
<MessagePicker
kind={widget.type}
value={str(cfg[isInput ? "target" : "message"])}
label={isInput ? "Publishes to" : "Shows"}
testId="widget-message"
onPick={(message, dtype) =>
set({ [isInput ? "target" : "message"]: message, dtype })
}
/>
)}
</div>
{widget.type === "chart" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Points kept</Label>
<Input
type="number"
value={str(
(cfg.history as { points?: number } | undefined)?.points ?? 300,
)}
onChange={(event) =>
set({ history: { points: Number(event.target.value) || 0 } })
}
/>
<p className="text-xs text-muted-foreground">
How much past the engine keeps for these messages.
</p>
</div>
) : null}
{widget.type === "stat" || widget.type === "gauge" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Unit</Label>
<Input
value={str(cfg.unit)}
placeholder="°C"
onChange={(event) => set({ unit: event.target.value })}
/>
</div>
) : null}
{widget.type === "gauge" || widget.type === "slider" ? (
<div className="flex gap-2">
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Minimum</Label>
<Input
type="number"
value={str(cfg.min ?? 0)}
onChange={(event) =>
set({ min: Number(event.target.value) || 0 })
}
/>
</div>
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Maximum</Label>
<Input
type="number"
value={str(cfg.max ?? 100)}
onChange={(event) =>
set({ max: Number(event.target.value) || 0 })
}
/>
</div>
</div>
) : null}
{widget.type === "button" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Sends</Label>
<Input
value={str(cfg.value)}
placeholder="true"
onChange={(event) => {
const raw = event.target.value
const asNumber = Number(raw)
set({
value:
raw === "true" || raw === "false"
? raw === "true"
: raw !== "" && Number.isFinite(asNumber)
? asNumber
: raw,
})
}}
/>
</div>
) : null}
</div>
</SidePanel>
)
}
/**
* The dashboard's own settings, in the panel its widgets use.
*
* The grid size is the one that matters: a wall panel is a fixed width, and
* twelve columns on a seven-inch screen is a different dashboard than twelve
* on a television.
*/
export function DashboardPanel({
open,
dashboard,
widgetCount,
onChange,
onDelete,
onClose,
}: {
open: boolean
dashboard: Dashboard
widgetCount: number
onChange: (changes: Partial<Dashboard>) => void
onDelete: () => void
onClose: () => void
}) {
const [confirmOpen, setConfirmOpen] = useState(false)
return (
<>
<SidePanel
open={open}
label="Dashboard settings"
testId="dashboard-panel"
bodyKey={dashboard.name}
onClose={onClose}
header={
<PanelTitle
value={dashboard.title ?? ""}
placeholder={dashboard.name}
label="Dashboard title"
onConfirm={(title) => onChange({ title })}
/>
}
footer={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setConfirmOpen(true)}
data-testid="delete-dashboard"
>
Delete dashboard
</Button>
}
>
<div className="grid gap-5 p-4">
<div className="grid gap-2">
<span className={PANEL_SECTION}>Grid</span>
<Select
value={String(columnsOf(dashboard))}
onValueChange={(value) => onChange({ columns: Number(value) })}
>
<SelectTrigger data-testid="dashboard-columns">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLUMN_CHOICES.map((count) => (
<SelectItem key={count} value={String(count)}>
{count} columns
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
How many columns wide this dashboard is laid out, so it can be
matched to the panel it will hang on.
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Contents</span>
<p className="text-sm text-muted-foreground">
<span className="font-mono">{dashboard.name}</span> {" "}
{widgetCount === 0
? "nothing on it yet."
: `${widgetCount} widget${widgetCount === 1 ? "" : "s"}.`}
</p>
</div>
</div>
</SidePanel>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Delete {dashboard.title || dashboard.name}?
</DialogTitle>
<DialogDescription>
This removes the dashboard and its widgets. The flows it read from
are untouched, and its history stays in the flow store's git
repository.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
Keep it
</Button>
<Button
variant="destructive"
onClick={() => {
setConfirmOpen(false)
onDelete()
}}
data-testid="confirm-delete-dashboard"
>
Delete dashboard
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
+111 -3
View File
@@ -13,7 +13,13 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { ChartWidget } from "./ChartWidget"
import { usePublishMessage } from "./queries"
/** Widget types that put a value into the graph rather than read one. */
@@ -27,6 +33,27 @@ export const INPUT_WIDGETS = new Set([
export type WidgetKind = WidgetDef["type"]
/**
* What a widget can be pointed at, by payload type.
*
* A switch that reads a float has nothing to show and nothing safe to send, so
* the pairing is part of the document rather than a matter of taste. The same
* table is enforced on the server (`app/flow/dashboards.py`); a type missing
* from it takes anything.
*/
export const WIDGET_DTYPES: Partial<Record<WidgetKind, string[]>> = {
gauge: ["float", "int"],
chart: ["float", "int"],
slider: ["float", "int"],
switch: ["bool"],
}
/** Whether a message of this payload type may drive this kind of widget. */
export function acceptsDtype(kind: WidgetKind, dtype: string | undefined) {
const allowed = WIDGET_DTYPES[kind]
return !allowed || !dtype || allowed.includes(dtype)
}
export const WIDGET_LABELS: Record<WidgetKind, string> = {
stat: "Value",
gauge: "Gauge",
@@ -76,6 +103,51 @@ function format(value: unknown, precision: number | null): string {
return String(value)
}
/** One line of a chart, as the document stores it. */
export type Series = { message?: string; dtype?: string; label?: string }
export const seriesOf = (widget: WidgetDef): Series[] =>
(config(widget).series ?? []) as Series[]
/**
* What is wrong with this widget's wiring, if anything.
*
* Both halves are checked from the document alone — the picker records the
* payload type it bound — so a wall panel can flag a broken tile without
* fetching the message catalogue first.
*/
export function widgetIssue(widget: WidgetDef): string | null {
if (widget.type === "markdown") return null
const cfg = config(widget)
if (widget.type === "chart") {
const series = seriesOf(widget)
if (series.length === 0) return "This chart has no series yet."
const wrong = series.find(
(entry) => !entry.message || !acceptsDtype("chart", entry.dtype),
)
if (wrong) {
return wrong.message
? `${wrong.message} is a ${wrong.dtype}; a chart can only draw numbers.`
: "One of the series is not bound to a message."
}
return null
}
const input = INPUT_WIDGETS.has(widget.type)
const bound = text(cfg[input ? "target" : "message"])
if (!bound) {
return input
? "This control does not publish to a message yet."
: "This widget is not bound to a message yet."
}
const dtype = cfg.dtype === undefined ? undefined : text(cfg.dtype)
if (!acceptsDtype(widget.type, dtype)) {
return `${bound} is a ${dtype}; a ${WIDGET_LABELS[widget.type].toLowerCase()} cannot carry that.`
}
return null
}
/**
* The frame every widget sits in.
*
@@ -86,22 +158,40 @@ export function WidgetFrame({
title,
children,
actions,
issue,
grip,
className,
onClick,
}: {
title?: string
children: React.ReactNode
actions?: React.ReactNode
/** Mis-wired: the same red dot and tooltip a failing node carries. */
issue?: string | null
/** Make the header the handle the editor drags the widget by. */
grip?: boolean
className?: string
onClick?: React.MouseEventHandler<HTMLDivElement>
}) {
return (
// A card is not a control: the click only picks it in edit mode, and every
// interactive element inside keeps its own role and keyboard handling.
// biome-ignore lint/a11y/useKeyWithClickEvents: see above.
// biome-ignore lint/a11y/noStaticElementInteractions: see above.
<div
className={cn(
"flex h-full flex-col gap-2 overflow-hidden rounded-lg border border-border bg-card p-4 shadow-e1",
className,
)}
onClick={onClick}
>
{title || actions ? (
<div className="flex items-start justify-between gap-2">
{title || actions || issue || grip ? (
<div
className={cn(
"flex items-start justify-between gap-2",
grip && "widget-grip -m-1 cursor-grab p-1 active:cursor-grabbing",
)}
>
{title ? (
<span className="truncate text-sm text-muted-foreground">
{title}
@@ -109,7 +199,24 @@ export function WidgetFrame({
) : (
<span />
)}
{actions}
<span className="flex shrink-0 items-center gap-1.5">
{issue ? (
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
className="size-2 shrink-0 rounded-full bg-destructive"
aria-label={issue}
data-testid="widget-issue"
/>
</TooltipTrigger>
<TooltipContent className="max-w-xs break-words">
{issue}
</TooltipContent>
</Tooltip>
) : null}
{actions}
</span>
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col justify-center">
@@ -434,6 +541,7 @@ const RENDERERS: Partial<
> = {
stat: StatWidget,
gauge: GaugeWidget,
chart: ChartWidget,
markdown: MarkdownWidget,
button: ButtonWidget,
switch: SwitchWidget,
@@ -0,0 +1,31 @@
import { motion } from "motion/react"
import type { ReactNode } from "react"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { slideUp, transitions } from "@/lib/motion"
/**
* What you are looking at, floating top-centre over a full-bleed canvas.
*
* Reference rather than navigation: switching to another flow or dashboard is
* what the overviews are for, so this bar carries the name and nothing that
* takes you away from it. Shared by the flow editor and the dashboards so the
* two shells read as one.
*/
export function CanvasTitle({ children }: { children: ReactNode }) {
return (
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
className="pointer-events-auto absolute left-1/2 top-4 z-10 flex max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md"
>
{/* The sidebar carries its own collapse control; a phone has no sidebar
on screen to carry it. */}
<SidebarTrigger className="size-8 shrink-0 text-muted-foreground md:hidden" />
{children}
</motion.div>
)
}
+73
View File
@@ -1,12 +1,15 @@
import { useReactFlow } from "@xyflow/react"
import {
AlertCircle,
Check,
Loader2,
Maximize2,
Pause,
Pencil,
Play,
PlayCircle,
Plus,
WifiOff,
ZoomIn,
ZoomOut,
} from "lucide-react"
@@ -28,6 +31,7 @@ import {
import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { type LogsFilter, LogsPanel } from "./LogsPanel"
import { useLiveConnection } from "./liveStore"
/**
* What "fit" means on this canvas: the view a flow opens with, and the one the
@@ -40,6 +44,10 @@ export const FIT_VIEW = { padding: 0.25, maxZoom: 1.2 }
/**
* The action bar, floating bottom-centre. Run is the one brand-secondary
* affordance on this view; everything else stays quiet.
*
* Everything the flow bar used to carry is here too — whether the work is
* saved, the flow's own settings, and putting it live — so the top of the
* canvas is left to say which flow this is.
*/
export function FlowDock({
flow,
@@ -47,25 +55,36 @@ export function FlowDock({
running,
enabled,
paused,
saving,
hasDraft,
publishing,
logs,
onAddNode,
onRun,
onTogglePause,
onFocusNode,
onEditFlow,
onPublish,
}: {
flow: string
issues: ValidationIssue[]
running: boolean
enabled: boolean
paused: boolean
saving: boolean
hasDraft: boolean
publishing: boolean
/** Owned by the editor, because a failing node can open it too. */
logs: LogsFilter
onAddNode: () => void
onRun: () => void
onTogglePause: () => void
onFocusNode: (nodeId: string) => void
onEditFlow: () => void
onPublish: () => void
}) {
const { zoomIn, zoomOut, fitView } = useReactFlow()
const connected = useLiveConnection()
return (
<motion.div
@@ -219,6 +238,60 @@ export function FlowDock({
{enabled ? "Run every node once" : "Start the flow to run it"}
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 !h-5" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onEditFlow}
aria-label="Flow settings"
data-testid="edit-flow"
>
<Pencil />
</Button>
</TooltipTrigger>
<TooltipContent>Flow settings</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
{!connected ? (
<WifiOff className="size-3.5" />
) : saving ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
</span>
</TooltipTrigger>
<TooltipContent>
{!connected
? "Reconnecting to the engine"
: saving
? "Saving"
: hasDraft
? "Saved — publish to put it live"
: "All changes saved"}
</TooltipContent>
</Tooltip>
{hasDraft ? (
<Button
variant="outline"
size="sm"
className="h-11 shrink-0 rounded-full md:h-8"
onClick={onPublish}
disabled={publishing}
data-testid="publish-flow"
>
{publishing ? "Publishing…" : "Publish"}
</Button>
) : null}
</motion.div>
)
}
+14 -13
View File
@@ -40,6 +40,7 @@ import {
import useCustomToast from "@/hooks/useCustomToast"
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils"
import { CanvasTitle } from "./CanvasTitle"
import { CommandPalette } from "./CommandPalette"
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
@@ -54,7 +55,6 @@ import {
import { FIT_VIEW, FlowDock } from "./FlowDock"
import { FlowNode, type FlowNodeData } from "./FlowNode"
import { FlowPanel } from "./FlowPanel"
import { FlowTabs } from "./FlowTabs"
import { LiveEdge } from "./LiveEdge"
import { NodePanel } from "./NodePanel"
import "./flow.css"
@@ -934,18 +934,11 @@ function FlowEditorInner({
panelOpen && !editorExpanded && "md:right-[27rem]",
)}
>
<FlowTabs
flows={flows.data}
active={flowName}
saving={saving.isPending}
hasDraft={detail.has_draft ?? false}
publishing={publish.isPending || saving.isPending}
onPublish={() => void publishFlow()}
onEditFlow={() => {
setSelectedId(null)
setFlowPanelOpen(true)
}}
/>
<CanvasTitle>
<span className="truncate px-3 py-1.5 text-sm font-medium">
{flowDoc.title || flowName}
</span>
</CanvasTitle>
<FlowDock
flow={flowName}
@@ -953,6 +946,14 @@ function FlowEditorInner({
running={runMutation.isPending}
enabled={detail.enabled ?? true}
paused={paused}
saving={saving.isPending}
hasDraft={detail.has_draft ?? false}
publishing={publish.isPending || saving.isPending}
onEditFlow={() => {
setSelectedId(null)
setFlowPanelOpen(true)
}}
onPublish={() => void publishFlow()}
logs={{
open: logsOpen,
node: logsNode,
-239
View File
@@ -1,239 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Link, useNavigate } from "@tanstack/react-router"
import { Check, Loader2, Pencil, Plus, WifiOff } from "lucide-react"
import { motion } from "motion/react"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { type FlowSummary, FlowsService } from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { SidebarTrigger } from "@/components/ui/sidebar"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { useLiveConnection } from "./liveStore"
import { flowKeys } from "./queries"
const nameSchema = z.object({
name: z
.string()
.min(1, "Give the flow a name")
.regex(
/^[a-z][a-z0-9_]*$/,
"Lowercase letters, digits and underscores, starting with a letter",
),
})
type NameForm = z.infer<typeof nameSchema>
function NewFlowDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const form = useForm<NameForm>({
resolver: zodResolver(nameSchema),
defaultValues: { name: "" },
})
const mutation = useMutation({
mutationFn: (values: NameForm) =>
FlowsService.saveFlow({
name: values.name,
requestBody: { name: values.name, nodes: [], inputs: [] },
}),
onSuccess: (_data, values) => {
queryClient.invalidateQueries({ queryKey: flowKeys.all })
onOpenChange(false)
form.reset()
navigate({ to: "/flows/$flowName", params: { flowName: values.name } })
},
})
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
<DialogHeader>
<DialogTitle>New flow</DialogTitle>
<DialogDescription>
Flows are small on purpose. Name this one after what it does.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-4">
<Label htmlFor="flow-name">Name</Label>
<Input
id="flow-name"
data-testid="flow-name-input"
placeholder="heating"
autoComplete="off"
{...form.register("name")}
/>
{form.formState.errors.name ? (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
) : null}
</div>
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Creating…" : "Create flow"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
/**
* Flow switcher, floating top-centre over the canvas. Each flow is a chip:
* transparent at rest, filled when it is the one you are looking at.
*/
export function FlowTabs({
flows,
active,
saving,
hasDraft,
publishing,
onPublish,
onEditFlow,
}: {
flows: FlowSummary[]
active: string
saving: boolean
hasDraft: boolean
publishing: boolean
onPublish: () => void
onEditFlow: () => void
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const connected = useLiveConnection()
return (
<>
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
className="pointer-events-auto absolute left-1/2 top-4 z-10 flex max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md"
>
<SidebarTrigger className="size-8 shrink-0 text-muted-foreground md:hidden" />
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{flows.map((flow) => (
<Link
key={flow.name}
to="/flows/$flowName"
params={{ flowName: flow.name }}
className={cn(
"flex shrink-0 snap-start items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors",
flow.name === active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{flow.title || flow.name}
{flow.has_draft ? (
<span className="size-1.5 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unpublished changes</span>
</span>
) : null}
</Link>
))}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground"
onClick={() => setDialogOpen(true)}
aria-label="New flow"
>
<Plus />
</Button>
</TooltipTrigger>
<TooltipContent>New flow</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground"
onClick={onEditFlow}
aria-label="Flow settings"
data-testid="edit-flow"
>
<Pencil />
</Button>
</TooltipTrigger>
<TooltipContent>Flow settings</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
{!connected ? (
<WifiOff className="size-3.5" />
) : saving ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
</span>
</TooltipTrigger>
<TooltipContent>
{!connected
? "Reconnecting to the engine"
: saving
? "Saving"
: hasDraft
? "Saved — publish to put it live"
: "All changes saved"}
</TooltipContent>
</Tooltip>
{hasDraft ? (
<Button
variant="outline"
size="sm"
className="shrink-0 rounded-full"
onClick={onPublish}
disabled={publishing}
data-testid="publish-flow"
>
{publishing ? "Publishing…" : "Publish"}
</Button>
) : null}
</motion.div>
<NewFlowDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
)
}
+15
View File
@@ -69,6 +69,11 @@
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-status-success: var(--status-success);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--font-mono:
ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas,
monospace;
@@ -112,6 +117,11 @@
--sidebar-border: #e4e4e4;
--sidebar-ring: #4a7189;
--status-success: #5e8b6d;
--chart-1: #3f5f74;
--chart-2: #4f768e;
--chart-3: #688ba1;
--chart-4: #83a2b5;
--chart-5: #9eb8c9;
}
.dark {
@@ -144,6 +154,11 @@
--sidebar-border: #2a2a2a;
--sidebar-ring: #7ba3b8;
--status-success: #87b596;
--chart-1: #94b0be;
--chart-2: #7999aa;
--chart-3: #5f8396;
--chart-4: #496d7f;
--chart-5: #395767;
}
@layer base {
+159 -138
View File
@@ -9,39 +9,27 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as CanvasRouteImport } from './routes/_canvas'
import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as LoginRouteImport } from './routes/login'
import { Route as RecoverPasswordRouteImport } from './routes/recover-password'
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as SignupRouteImport } from './routes/signup'
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as RecoverPasswordRouteImport } from './routes/recover-password'
import { Route as LoginRouteImport } from './routes/login'
import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as CanvasRouteImport } from './routes/_canvas'
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
import { Route as LayoutAlertsRouteImport } from './routes/_layout/alerts'
import { Route as LayoutSecretsRouteImport } from './routes/_layout/secrets'
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
import { Route as ViewNameRouteImport } from './routes/view.$name'
import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize'
import { Route as CanvasFlowsIndexRouteImport } from './routes/_canvas/flows/index'
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
import { Route as LayoutSecretsRouteImport } from './routes/_layout/secrets'
import { Route as LayoutAlertsRouteImport } from './routes/_layout/alerts'
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
import { Route as LayoutFlowsIndexRouteImport } from './routes/_layout/flows/index'
import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/index'
import { Route as LayoutDashboardsNameRouteImport } from './routes/_layout/dashboards/$name'
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
import { Route as CanvasDashboardsNameRouteImport } from './routes/_canvas/dashboards/$name'
const CanvasRoute = CanvasRouteImport.update({
id: '/_canvas',
getParentRoute: () => rootRouteImport,
} as any)
const LayoutRoute = LayoutRouteImport.update({
id: '/_layout',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const RecoverPasswordRoute = RecoverPasswordRouteImport.update({
id: '/recover-password',
path: '/recover-password',
const SignupRoute = SignupRouteImport.update({
id: '/signup',
path: '/signup',
getParentRoute: () => rootRouteImport,
} as any)
const ResetPasswordRoute = ResetPasswordRouteImport.update({
@@ -49,9 +37,22 @@ const ResetPasswordRoute = ResetPasswordRouteImport.update({
path: '/reset-password',
getParentRoute: () => rootRouteImport,
} as any)
const SignupRoute = SignupRouteImport.update({
id: '/signup',
path: '/signup',
const RecoverPasswordRoute = RecoverPasswordRouteImport.update({
id: '/recover-password',
path: '/recover-password',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const LayoutRoute = LayoutRouteImport.update({
id: '/_layout',
getParentRoute: () => rootRouteImport,
} as any)
const CanvasRoute = CanvasRouteImport.update({
id: '/_canvas',
getParentRoute: () => rootRouteImport,
} as any)
const LayoutIndexRoute = LayoutIndexRouteImport.update({
@@ -59,14 +60,19 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({
path: '/',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutAdminRoute = LayoutAdminRouteImport.update({
id: '/admin',
path: '/admin',
getParentRoute: () => LayoutRoute,
const ViewNameRoute = ViewNameRouteImport.update({
id: '/view/$name',
path: '/view/$name',
getParentRoute: () => rootRouteImport,
} as any)
const LayoutAlertsRoute = LayoutAlertsRouteImport.update({
id: '/alerts',
path: '/alerts',
const OauthAuthorizeRoute = OauthAuthorizeRouteImport.update({
id: '/oauth/authorize',
path: '/oauth/authorize',
getParentRoute: () => rootRouteImport,
} as any)
const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutSecretsRoute = LayoutSecretsRouteImport.update({
@@ -74,35 +80,35 @@ const LayoutSecretsRoute = LayoutSecretsRouteImport.update({
path: '/secrets',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
id: '/settings',
path: '/settings',
const LayoutAlertsRoute = LayoutAlertsRouteImport.update({
id: '/alerts',
path: '/alerts',
getParentRoute: () => LayoutRoute,
} as any)
const OauthAuthorizeRoute = OauthAuthorizeRouteImport.update({
id: '/oauth/authorize',
path: '/oauth/authorize',
getParentRoute: () => rootRouteImport,
const LayoutAdminRoute = LayoutAdminRouteImport.update({
id: '/admin',
path: '/admin',
getParentRoute: () => LayoutRoute,
} as any)
const CanvasFlowsIndexRoute = CanvasFlowsIndexRouteImport.update({
const LayoutFlowsIndexRoute = LayoutFlowsIndexRouteImport.update({
id: '/flows/',
path: '/flows/',
getParentRoute: () => CanvasRoute,
} as any)
const CanvasFlowsFlowNameRoute = CanvasFlowsFlowNameRouteImport.update({
id: '/flows/$flowName',
path: '/flows/$flowName',
getParentRoute: () => CanvasRoute,
getParentRoute: () => LayoutRoute,
} as any)
const LayoutDashboardsIndexRoute = LayoutDashboardsIndexRouteImport.update({
id: '/dashboards/',
path: '/dashboards/',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutDashboardsNameRoute = LayoutDashboardsNameRouteImport.update({
const CanvasFlowsFlowNameRoute = CanvasFlowsFlowNameRouteImport.update({
id: '/flows/$flowName',
path: '/flows/$flowName',
getParentRoute: () => CanvasRoute,
} as any)
const CanvasDashboardsNameRoute = CanvasDashboardsNameRouteImport.update({
id: '/dashboards/$name',
path: '/dashboards/$name',
getParentRoute: () => LayoutRoute,
getParentRoute: () => CanvasRoute,
} as any)
export interface FileRoutesByFullPath {
@@ -116,10 +122,11 @@ export interface FileRoutesByFullPath {
'/secrets': typeof LayoutSecretsRoute
'/settings': typeof LayoutSettingsRoute
'/oauth/authorize': typeof OauthAuthorizeRoute
'/view/$name': typeof ViewNameRoute
'/dashboards/$name': typeof CanvasDashboardsNameRoute
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/dashboards/$name': typeof LayoutDashboardsNameRoute
'/flows/': typeof CanvasFlowsIndexRoute
'/dashboards/': typeof LayoutDashboardsIndexRoute
'/flows/': typeof LayoutFlowsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof LayoutIndexRoute
@@ -132,10 +139,11 @@ export interface FileRoutesByTo {
'/secrets': typeof LayoutSecretsRoute
'/settings': typeof LayoutSettingsRoute
'/oauth/authorize': typeof OauthAuthorizeRoute
'/view/$name': typeof ViewNameRoute
'/dashboards/$name': typeof CanvasDashboardsNameRoute
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/dashboards/$name': typeof LayoutDashboardsNameRoute
'/flows': typeof CanvasFlowsIndexRoute
'/dashboards': typeof LayoutDashboardsIndexRoute
'/flows': typeof LayoutFlowsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -150,11 +158,12 @@ export interface FileRoutesById {
'/_layout/secrets': typeof LayoutSecretsRoute
'/_layout/settings': typeof LayoutSettingsRoute
'/oauth/authorize': typeof OauthAuthorizeRoute
'/view/$name': typeof ViewNameRoute
'/_layout/': typeof LayoutIndexRoute
'/_canvas/dashboards/$name': typeof CanvasDashboardsNameRoute
'/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/_layout/dashboards/$name': typeof LayoutDashboardsNameRoute
'/_canvas/flows/': typeof CanvasFlowsIndexRoute
'/_layout/dashboards/': typeof LayoutDashboardsIndexRoute
'/_layout/flows/': typeof LayoutFlowsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -169,10 +178,11 @@ export interface FileRouteTypes {
| '/secrets'
| '/settings'
| '/oauth/authorize'
| '/flows/$flowName'
| '/view/$name'
| '/dashboards/$name'
| '/flows/'
| '/flows/$flowName'
| '/dashboards/'
| '/flows/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -185,10 +195,11 @@ export interface FileRouteTypes {
| '/secrets'
| '/settings'
| '/oauth/authorize'
| '/flows/$flowName'
| '/view/$name'
| '/dashboards/$name'
| '/flows'
| '/flows/$flowName'
| '/dashboards'
| '/flows'
id:
| '__root__'
| '/_canvas'
@@ -202,11 +213,12 @@ export interface FileRouteTypes {
| '/_layout/secrets'
| '/_layout/settings'
| '/oauth/authorize'
| '/view/$name'
| '/_layout/'
| '/_canvas/dashboards/$name'
| '/_canvas/flows/$flowName'
| '/_layout/dashboards/$name'
| '/_canvas/flows/'
| '/_layout/dashboards/'
| '/_layout/flows/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -217,36 +229,16 @@ export interface RootRouteChildren {
ResetPasswordRoute: typeof ResetPasswordRoute
SignupRoute: typeof SignupRoute
OauthAuthorizeRoute: typeof OauthAuthorizeRoute
ViewNameRoute: typeof ViewNameRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_canvas': {
id: '/_canvas'
path: ''
fullPath: '/'
preLoaderRoute: typeof CanvasRouteImport
parentRoute: typeof rootRouteImport
}
'/_layout': {
id: '/_layout'
path: ''
fullPath: '/'
preLoaderRoute: typeof LayoutRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/recover-password': {
id: '/recover-password'
path: '/recover-password'
fullPath: '/recover-password'
preLoaderRoute: typeof RecoverPasswordRouteImport
'/signup': {
id: '/signup'
path: '/signup'
fullPath: '/signup'
preLoaderRoute: typeof SignupRouteImport
parentRoute: typeof rootRouteImport
}
'/reset-password': {
@@ -256,11 +248,32 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ResetPasswordRouteImport
parentRoute: typeof rootRouteImport
}
'/signup': {
id: '/signup'
path: '/signup'
fullPath: '/signup'
preLoaderRoute: typeof SignupRouteImport
'/recover-password': {
id: '/recover-password'
path: '/recover-password'
fullPath: '/recover-password'
preLoaderRoute: typeof RecoverPasswordRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/_layout': {
id: '/_layout'
path: ''
fullPath: '/'
preLoaderRoute: typeof LayoutRouteImport
parentRoute: typeof rootRouteImport
}
'/_canvas': {
id: '/_canvas'
path: ''
fullPath: '/'
preLoaderRoute: typeof CanvasRouteImport
parentRoute: typeof rootRouteImport
}
'/_layout/': {
@@ -270,18 +283,25 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutIndexRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/admin': {
id: '/_layout/admin'
path: '/admin'
fullPath: '/admin'
preLoaderRoute: typeof LayoutAdminRouteImport
parentRoute: typeof LayoutRoute
'/view/$name': {
id: '/view/$name'
path: '/view/$name'
fullPath: '/view/$name'
preLoaderRoute: typeof ViewNameRouteImport
parentRoute: typeof rootRouteImport
}
'/_layout/alerts': {
id: '/_layout/alerts'
path: '/alerts'
fullPath: '/alerts'
preLoaderRoute: typeof LayoutAlertsRouteImport
'/oauth/authorize': {
id: '/oauth/authorize'
path: '/oauth/authorize'
fullPath: '/oauth/authorize'
preLoaderRoute: typeof OauthAuthorizeRouteImport
parentRoute: typeof rootRouteImport
}
'/_layout/settings': {
id: '/_layout/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof LayoutSettingsRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/secrets': {
@@ -291,33 +311,26 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutSecretsRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/settings': {
id: '/_layout/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof LayoutSettingsRouteImport
'/_layout/alerts': {
id: '/_layout/alerts'
path: '/alerts'
fullPath: '/alerts'
preLoaderRoute: typeof LayoutAlertsRouteImport
parentRoute: typeof LayoutRoute
}
'/oauth/authorize': {
id: '/oauth/authorize'
path: '/oauth/authorize'
fullPath: '/oauth/authorize'
preLoaderRoute: typeof OauthAuthorizeRouteImport
parentRoute: typeof rootRouteImport
'/_layout/admin': {
id: '/_layout/admin'
path: '/admin'
fullPath: '/admin'
preLoaderRoute: typeof LayoutAdminRouteImport
parentRoute: typeof LayoutRoute
}
'/_canvas/flows/': {
id: '/_canvas/flows/'
'/_layout/flows/': {
id: '/_layout/flows/'
path: '/flows'
fullPath: '/flows/'
preLoaderRoute: typeof CanvasFlowsIndexRouteImport
parentRoute: typeof CanvasRoute
}
'/_canvas/flows/$flowName': {
id: '/_canvas/flows/$flowName'
path: '/flows/$flowName'
fullPath: '/flows/$flowName'
preLoaderRoute: typeof CanvasFlowsFlowNameRouteImport
parentRoute: typeof CanvasRoute
preLoaderRoute: typeof LayoutFlowsIndexRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/dashboards/': {
id: '/_layout/dashboards/'
@@ -326,24 +339,31 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutDashboardsIndexRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/dashboards/$name': {
id: '/_layout/dashboards/$name'
'/_canvas/flows/$flowName': {
id: '/_canvas/flows/$flowName'
path: '/flows/$flowName'
fullPath: '/flows/$flowName'
preLoaderRoute: typeof CanvasFlowsFlowNameRouteImport
parentRoute: typeof CanvasRoute
}
'/_canvas/dashboards/$name': {
id: '/_canvas/dashboards/$name'
path: '/dashboards/$name'
fullPath: '/dashboards/$name'
preLoaderRoute: typeof LayoutDashboardsNameRouteImport
parentRoute: typeof LayoutRoute
preLoaderRoute: typeof CanvasDashboardsNameRouteImport
parentRoute: typeof CanvasRoute
}
}
}
interface CanvasRouteChildren {
CanvasDashboardsNameRoute: typeof CanvasDashboardsNameRoute
CanvasFlowsFlowNameRoute: typeof CanvasFlowsFlowNameRoute
CanvasFlowsIndexRoute: typeof CanvasFlowsIndexRoute
}
const CanvasRouteChildren: CanvasRouteChildren = {
CanvasDashboardsNameRoute: CanvasDashboardsNameRoute,
CanvasFlowsFlowNameRoute: CanvasFlowsFlowNameRoute,
CanvasFlowsIndexRoute: CanvasFlowsIndexRoute,
}
const CanvasRouteWithChildren =
@@ -355,8 +375,8 @@ interface LayoutRouteChildren {
LayoutSecretsRoute: typeof LayoutSecretsRoute
LayoutSettingsRoute: typeof LayoutSettingsRoute
LayoutIndexRoute: typeof LayoutIndexRoute
LayoutDashboardsNameRoute: typeof LayoutDashboardsNameRoute
LayoutDashboardsIndexRoute: typeof LayoutDashboardsIndexRoute
LayoutFlowsIndexRoute: typeof LayoutFlowsIndexRoute
}
const LayoutRouteChildren: LayoutRouteChildren = {
@@ -365,8 +385,8 @@ const LayoutRouteChildren: LayoutRouteChildren = {
LayoutSecretsRoute: LayoutSecretsRoute,
LayoutSettingsRoute: LayoutSettingsRoute,
LayoutIndexRoute: LayoutIndexRoute,
LayoutDashboardsNameRoute: LayoutDashboardsNameRoute,
LayoutDashboardsIndexRoute: LayoutDashboardsIndexRoute,
LayoutFlowsIndexRoute: LayoutFlowsIndexRoute,
}
const LayoutRouteWithChildren =
@@ -380,6 +400,7 @@ const rootRouteChildren: RootRouteChildren = {
ResetPasswordRoute: ResetPasswordRoute,
SignupRoute: SignupRoute,
OauthAuthorizeRoute: OauthAuthorizeRoute,
ViewNameRoute: ViewNameRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
@@ -0,0 +1,38 @@
import { useQuery } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { DashboardEditor } from "@/components/Dashboard/DashboardEditor"
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
type Search = { edit?: boolean }
export const Route = createFileRoute("/_canvas/dashboards/$name")({
component: DashboardRoute,
// Edit mode is a search param so viewing stays the default a panel opens
// into, and an editing session is a link someone can send.
validateSearch: (search: Record<string, unknown>): Search => ({
edit: search.edit === true || search.edit === "true" || undefined,
}),
head: ({ params }) => ({ meta: [{ title: `${params.name} - Fluksio` }] }),
})
function DashboardRoute() {
const { name } = Route.useParams()
const { edit } = Route.useSearch()
// Widgets read live values; the canvas shell has no socket of its own.
useFlowSocket()
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
if (!dashboard) return null
return (
<DashboardEditor
// Leaving edit mode drops the draft, so the session starts clean.
key={`${name}:${edit ? "edit" : "view"}`}
dashboard={dashboard as Dashboard}
edit={Boolean(edit)}
/>
)
}
@@ -1,84 +0,0 @@
import {
useMutation,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query"
import { createFileRoute, Navigate, useNavigate } from "@tanstack/react-router"
import { Workflow } from "lucide-react"
import { Suspense } from "react"
import { FlowsService } from "@/client"
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
export const Route = createFileRoute("/_canvas/flows/")({
component: () => (
<Suspense fallback={<Loading />}>
<FlowsIndex />
</Suspense>
),
head: () => ({ meta: [{ title: "Flows - Fluksio" }] }),
})
function Loading() {
return (
<div className="flex h-full items-center justify-center">
<Skeleton className="h-32 w-64 rounded-lg" />
</div>
)
}
function FlowsIndex() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: flows } = useSuspenseQuery(flowsQueryOptions())
const create = useMutation({
mutationFn: () =>
FlowsService.saveFlow({
name: "my_first_flow",
requestBody: { name: "my_first_flow", nodes: [], inputs: [] },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: flowKeys.all })
navigate({
to: "/flows/$flowName",
params: { flowName: "my_first_flow" },
})
},
})
// With flows around, open the first one rather than showing an empty page.
if (flows.data.length > 0) {
return (
<Navigate
to="/flows/$flowName"
params={{ flowName: flows.data[0].name }}
replace
/>
)
}
return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-4 text-center">
<span className="flex size-16 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Workflow className="size-7" />
</span>
<div className="grid gap-1">
<h1 className="font-display text-lg font-medium">No flows yet</h1>
<p className="max-w-sm text-sm text-muted-foreground">
A flow is a handful of nodes passing messages to each other. Start
with one and add nodes as you go.
</p>
</div>
<Button
onClick={() => create.mutate()}
disabled={create.isPending}
data-testid="create-first-flow"
>
{create.isPending ? "Creating…" : "Create a flow"}
</Button>
</div>
)
}
@@ -1,111 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { Check, Pencil, Trash2 } from "lucide-react"
import { useEffect, useState } from "react"
import { type DashboardDef_Output, DashboardsService } from "@/client"
import { DashboardEditor } from "@/components/Dashboard/DashboardEditor"
import { DashboardView, pagesOf } from "@/components/Dashboard/DashboardView"
import {
dashboardKeys,
dashboardQueryOptions,
} from "@/components/Dashboard/queries"
import { Button } from "@/components/ui/button"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
type Search = { edit?: boolean }
export const Route = createFileRoute("/_layout/dashboards/$name")({
component: Dashboard,
// Edit mode is a search param so viewing stays the default a panel opens
// into, and an editing session is a link someone can send.
validateSearch: (search: Record<string, unknown>): Search => ({
edit: search.edit === true || search.edit === "true" || undefined,
}),
})
function Dashboard() {
const { name } = Route.useParams()
const { edit } = Route.useSearch()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast()
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
const [pageId, setPageId] = useState<string | undefined>(undefined)
useEffect(() => {
if (dashboard && !pageId) setPageId(pagesOf(dashboard)[0]?.id)
}, [dashboard, pageId])
const remove = useMutation({
mutationFn: () => DashboardsService.deleteDashboard({ name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
navigate({ to: "/dashboards" })
},
onError: handleError.bind(showErrorToast),
})
if (!dashboard) return null
const setEdit = (next: boolean) =>
navigate({
to: "/dashboards/$name",
params: { name },
search: next ? { edit: true } : {},
})
return (
<div className="grid gap-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl">{dashboard.title || dashboard.name}</h1>
<div className="flex items-center gap-2">
{edit ? (
<Button
variant="secondary"
onClick={() => remove.mutate()}
data-testid="delete-dashboard"
>
<Trash2 />
Delete
</Button>
) : null}
<Button
variant={edit ? "brand" : "secondary"}
onClick={() => setEdit(!edit)}
data-testid="toggle-edit"
>
{edit ? <Check /> : <Pencil />}
{edit ? "Done" : "Edit"}
</Button>
</div>
</div>
{pagesOf(dashboard).length > 1 ? (
<Tabs value={pageId} onValueChange={setPageId}>
<TabsList>
{pagesOf(dashboard).map((page) => (
<TabsTrigger key={page.id} value={page.id}>
{page.title || page.id}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
{edit ? (
<DashboardEditor
dashboard={dashboard as DashboardDef_Output}
pageId={pageId}
/>
) : (
<DashboardView
dashboard={dashboard as DashboardDef_Output}
pageId={pageId}
/>
)}
</div>
)
}
@@ -23,6 +23,7 @@ function Dashboards() {
const navigate = useNavigate()
const { showErrorToast } = useCustomToast()
const [name, setName] = useState("")
const [search, setSearch] = useState("")
const create = useMutation({
mutationFn: (dashboard: string) =>
@@ -38,12 +39,15 @@ function Dashboards() {
onError: handleError.bind(showErrorToast),
})
const dashboards = data?.data ?? []
// The store only accepts this shape, so say so before the request does.
const slug = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
const needle = search.trim().toLowerCase()
const dashboards = (data?.data ?? []).filter((dashboard) =>
`${dashboard.name} ${dashboard.title ?? ""}`.toLowerCase().includes(needle),
)
return (
<div className="grid gap-6">
@@ -54,34 +58,46 @@ function Dashboards() {
</p>
</div>
<form
className="flex max-w-md items-center gap-2"
onSubmit={(event) => {
event.preventDefault()
if (slug) create.mutate(slug)
}}
>
<div className="flex flex-wrap items-center gap-2">
<Input
value={name}
placeholder="New dashboard"
aria-label="New dashboard name"
data-testid="new-dashboard-name"
onChange={(event) => setName(event.target.value)}
value={search}
placeholder="Search dashboards"
aria-label="Search dashboards"
className="max-w-xs"
data-testid="search-dashboards"
onChange={(event) => setSearch(event.target.value)}
/>
<Button
type="submit"
variant="secondary"
disabled={!slug || create.isPending}
data-testid="create-dashboard"
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault()
if (slug) create.mutate(slug)
}}
>
<Plus />
Create
</Button>
</form>
<Input
value={name}
placeholder="New dashboard"
aria-label="New dashboard name"
data-testid="new-dashboard-name"
onChange={(event) => setName(event.target.value)}
/>
<Button
type="submit"
variant="secondary"
disabled={!slug || create.isPending}
data-testid="create-dashboard"
>
<Plus />
Create
</Button>
</form>
</div>
{dashboards.length === 0 ? (
<p className="text-sm text-muted-foreground">
No dashboards yet. Name one above to start.
{needle
? "No dashboard matches that."
: "No dashboards yet. Name one above to start."}
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
+133
View File
@@ -0,0 +1,133 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { Plus, Workflow } from "lucide-react"
import { useState } from "react"
import { FlowsService } from "@/client"
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
export const Route = createFileRoute("/_layout/flows/")({
component: Flows,
head: () => ({ meta: [{ title: "Flows - Fluksio" }] }),
})
/** The shape the store accepts, so a bad name is caught before the request. */
const NAME = /^[a-z][a-z0-9_]*$/
function Flows() {
const { data } = useQuery(flowsQueryOptions())
const queryClient = useQueryClient()
const navigate = useNavigate()
const { showErrorToast } = useCustomToast()
const [name, setName] = useState("")
const [search, setSearch] = useState("")
const create = useMutation({
mutationFn: (flow: string) =>
FlowsService.saveFlow({
name: flow,
requestBody: { name: flow, nodes: [], inputs: [] },
}),
onSuccess: (_saved, flow) => {
queryClient.invalidateQueries({ queryKey: flowKeys.all })
navigate({ to: "/flows/$flowName", params: { flowName: flow } })
},
onError: handleError.bind(showErrorToast),
})
const slug = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
const needle = search.trim().toLowerCase()
const flows = (data?.data ?? []).filter((flow) =>
`${flow.name} ${flow.title ?? ""}`.toLowerCase().includes(needle),
)
return (
<div className="grid gap-6">
<div className="grid gap-1">
<h1 className="text-2xl">Flows</h1>
<p className="text-sm text-muted-foreground">
A flow is a handful of nodes passing messages to each other. Keep them
small and name them after what they do.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
value={search}
placeholder="Search flows"
aria-label="Search flows"
className="max-w-xs"
data-testid="search-flows"
onChange={(event) => setSearch(event.target.value)}
/>
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault()
if (NAME.test(slug)) create.mutate(slug)
}}
>
<Input
value={name}
placeholder="New flow"
aria-label="New flow name"
data-testid="new-flow-name"
onChange={(event) => setName(event.target.value)}
/>
<Button
type="submit"
variant="secondary"
disabled={!NAME.test(slug) || create.isPending}
data-testid="create-flow"
>
<Plus />
Create
</Button>
</form>
</div>
{flows.length === 0 ? (
<p className="text-sm text-muted-foreground">
{needle
? "No flow matches that."
: "No flows yet. Name one above to start."}
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{flows.map((flow) => (
<Link
key={flow.name}
to="/flows/$flowName"
params={{ flowName: flow.name }}
className="grid gap-1 rounded-lg border border-border bg-card p-4 shadow-e1 transition-colors hover:bg-accent/50"
data-testid="flow-card"
>
<span className="flex items-center gap-2">
<Workflow className="size-4 text-muted-foreground" />
{flow.title || flow.name}
{flow.has_draft ? (
<span className="size-1.5 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unpublished changes</span>
</span>
) : null}
</span>
<span className="text-sm text-muted-foreground">
{flow.node_count ?? 0} node
{flow.node_count === 1 ? "" : "s"} ·{" "}
{flow.enabled === false ? "stopped" : "running"}
{flow.error_count ? ` · ${flow.error_count} to fix` : ""}
</span>
</Link>
))}
</div>
)}
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { useQuery } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router"
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { DashboardView } from "@/components/Dashboard/DashboardView"
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
import { isLoggedIn } from "@/hooks/useAuth"
/**
* What a wall panel is pointed at.
*
* Deliberately outside both shells: no sidebar, no footer, no editing, and no
* column cap — the widgets are the whole page. Route splitting means a panel
* never downloads the editor or the grid library either.
*/
export const Route = createFileRoute("/view/$name")({
component: PanelView,
beforeLoad: async () => {
if (!isLoggedIn()) {
throw redirect({ to: "/login" })
}
},
head: ({ params }) => ({ meta: [{ title: `${params.name} - Fluksio` }] }),
})
function PanelView() {
const { name } = Route.useParams()
useFlowSocket()
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
return (
<main className="dot-canvas min-h-svh w-full overflow-y-auto p-4">
{dashboard ? <DashboardView dashboard={dashboard as Dashboard} /> : null}
</main>
)
}
+2 -3
View File
@@ -19,9 +19,8 @@ test.afterAll(async ({ browser }) => {
test("a draft stays off the engine until it is published", async ({ page }) => {
await page.goto("/flows")
await page.getByRole("button", { name: "New flow" }).click()
await page.getByTestId("flow-name-input").fill(flowName)
await page.getByRole("button", { name: "Create flow" }).click()
await page.getByTestId("new-flow-name").fill(flowName)
await page.getByTestId("create-flow").click()
await page.waitForURL(`/flows/${flowName}`)
await page.getByTestId("add-node").click()
+2 -3
View File
@@ -73,9 +73,8 @@ test("a flow can be created, wired up, and comes back after a reload", async ({
await page.goto("/flows")
// Create a flow of our own so the test does not lean on existing data.
await page.getByRole("button", { name: "New flow" }).click()
await page.getByTestId("flow-name-input").fill(flowName)
await page.getByRole("button", { name: "Create flow" }).click()
await page.getByTestId("new-flow-name").fill(flowName)
await page.getByTestId("create-flow").click()
await page.waitForURL(`/flows/${flowName}`)
await addFunctionNode(page, 1)