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:
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user