Build dashboards you can actually look at and press
Widgets bind to a message name and read it live off the socket the editor already had — lifted out of the flow editor so a dashboard route gets the same values, which also gives the home page live data for free. The input widgets close the loop the other way: a slider publishes into the graph and whatever consumes that message runs. Verified end to end in the running app — moving a slider set a flow input, and the stat bound to what the flow computed from it followed. View mode is plain CSS grid. A wall panel that only displays should not download the code that lets someone drag things around, and it now does not. Editing is a widget picker, a per-widget width control and a settings card fed by the message catalog. No new dependencies: the slider is a range input, the gauge is an arc, and the markdown is a five-line subset. Charts are the one widget still missing — they need a charting library and the chart tokens the design guidelines reserved — so they are stored and validated but not offered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
@@ -69,6 +69,13 @@ Deferring because out of scope is fine, but don't mention deferring than.
|
|||||||
cannot be one — say so where the return value is mapped.
|
cannot be one — say so where the return value is mapped.
|
||||||
- PERF/FLOW: every save rebuilds the whole pipeline. Fine at the current flow count; rebuild
|
- PERF/FLOW: every save rebuilds the whole pipeline. Fine at the current flow count; rebuild
|
||||||
only the touched flow when it starts to show.
|
only the touched flow when it starts to show.
|
||||||
|
- FEAT/UI: the chart widget is stored and validated but not drawn — it is filtered out of
|
||||||
|
the editor's add row. Needs a charting library and the `--chart-*` tokens below.
|
||||||
|
- FEAT/UI: dashboard widgets are sized with a wider/narrower control rather than dragged.
|
||||||
|
A grid library would give drag-and-resize and per-breakpoint layouts; the document
|
||||||
|
already stores layout per breakpoint, so only the editor changes.
|
||||||
|
- FEAT/UI/MOBILE: dashboards render in the padded admin shell, so a wall panel gets the
|
||||||
|
sidebar, footer and a `max-w-7xl` column. A full-bleed shell would suit a panel better.
|
||||||
- FEAT/UI: reintroduce `--chart-*` tokens as one designed sequential scale when the first
|
- FEAT/UI: reintroduce `--chart-*` tokens as one designed sequential scale when the first
|
||||||
chart lands. The node sparkline draws one series in `--primary` and needs none.
|
chart lands. The node sparkline draws one series in `--primary` and needs none.
|
||||||
- PERF/UI: the app's entry chunk is 680 kB (210 kB gzipped) and exceeds the warning
|
- PERF/UI: the app's entry chunk is 680 kB (210 kB gzipped) and exceeds the warning
|
||||||
|
|||||||
+3
-1
@@ -151,7 +151,9 @@ React + Vite, primarily desktop but usable on mobile. See `docs/architecture/str
|
|||||||
Shares components with the admin view. See `docs/architecture/structure.canvas` →
|
Shares components with the admin view. See `docs/architecture/structure.canvas` →
|
||||||
*Frontend – Dashboard View*.
|
*Frontend – Dashboard View*.
|
||||||
|
|
||||||
- [ ] User-defined dashboard layout with edit and view modes
|
- [x] User-defined dashboard layout with edit and view modes: dashboards are their own
|
||||||
|
documents, widgets bind to message names, and the input ones publish back. View
|
||||||
|
mode is plain CSS grid, so a panel that only displays loads no editing code
|
||||||
- [ ] Responsive layout targeting wall panels, mobile and desktop
|
- [ ] Responsive layout targeting wall panels, mobile and desktop
|
||||||
- [ ] Per-device view
|
- [ ] Per-device view
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/** Feature check: build a dashboard, read a live value, move a control. */
|
||||||
|
import { mkdir } from "node:fs/promises"
|
||||||
|
import { chromium } from "@playwright/test"
|
||||||
|
|
||||||
|
const APP_URL = process.env.APP_URL || "http://app.localhost"
|
||||||
|
const EMAIL = process.env.FIRST_SUPERUSER
|
||||||
|
const PASSWORD = process.env.FIRST_SUPERUSER_PASSWORD
|
||||||
|
const OUT = process.env.SCREENSHOT_DIR || "screenshots"
|
||||||
|
const NAME = process.env.DASHBOARD_NAME || "house"
|
||||||
|
|
||||||
|
const browser = await chromium.launch()
|
||||||
|
for (const theme of ["light", "dark"]) {
|
||||||
|
const dir = `${OUT}/${theme}`
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
colorScheme: theme,
|
||||||
|
})
|
||||||
|
await context.addInitScript((t) => {
|
||||||
|
localStorage.setItem("fluksio-ui-theme", t)
|
||||||
|
}, theme)
|
||||||
|
const page = await context.newPage()
|
||||||
|
|
||||||
|
await page.goto(`${APP_URL}/login`, { waitUntil: "networkidle" })
|
||||||
|
await page.getByTestId("email-input").fill(EMAIL)
|
||||||
|
await page.getByTestId("password-input").fill(PASSWORD)
|
||||||
|
await page.getByRole("button", { name: /log in/i }).click()
|
||||||
|
await page.waitForURL(`${APP_URL}/`, { timeout: 15000 })
|
||||||
|
|
||||||
|
await page.goto(`${APP_URL}/dashboards`, { waitUntil: "networkidle" })
|
||||||
|
|
||||||
|
// Light builds it; dark just looks at what light left behind.
|
||||||
|
if (theme === "light") {
|
||||||
|
await page.getByTestId("new-dashboard-name").fill(NAME)
|
||||||
|
await page.getByTestId("create-dashboard").click()
|
||||||
|
await page.waitForURL(/\/dashboards\/.+/, { timeout: 15000 })
|
||||||
|
|
||||||
|
// A reading, a dial and a control over the same message.
|
||||||
|
for (const [kind, message] of [
|
||||||
|
["stat", "heating.applied"],
|
||||||
|
["gauge", "heating.setpoint"],
|
||||||
|
["slider", "heating.setpoint"],
|
||||||
|
]) {
|
||||||
|
await page.getByTestId(`add-widget-${kind}`).click()
|
||||||
|
await page.waitForSelector("[data-testid=widget-settings]")
|
||||||
|
await page.getByTestId("widget-message").click()
|
||||||
|
await page.getByRole("option", { name: message }).click()
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(1500) // let the autosave land
|
||||||
|
} else {
|
||||||
|
await page.getByTestId("dashboard-card").first().click()
|
||||||
|
await page.waitForURL(/\/dashboards\/.+/, { timeout: 15000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// View mode is what a panel shows.
|
||||||
|
const done = page.getByTestId("toggle-edit")
|
||||||
|
if ((await done.textContent())?.includes("Done")) await done.click()
|
||||||
|
await page.waitForTimeout(1200)
|
||||||
|
await page.screenshot({ path: `${dir}/app-dashboard-view.png` })
|
||||||
|
|
||||||
|
if (theme === "light") {
|
||||||
|
// Move the slider and confirm the value actually reached the engine.
|
||||||
|
const slider = page.locator('input[type="range"]').first()
|
||||||
|
await slider.click()
|
||||||
|
await slider.press("ArrowRight")
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
await page.screenshot({ path: `${dir}/app-dashboard-control.png` })
|
||||||
|
|
||||||
|
await page.getByTestId("toggle-edit").click()
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
await page.screenshot({ path: `${dir}/app-dashboard-edit.png` })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ${theme}: done`)
|
||||||
|
await context.close()
|
||||||
|
}
|
||||||
|
await browser.close()
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
|
import { handleError } from "@/utils"
|
||||||
|
import {
|
||||||
|
pagesOf,
|
||||||
|
SectionGrid,
|
||||||
|
sectionsOf,
|
||||||
|
widgetStyle,
|
||||||
|
widgetsOf,
|
||||||
|
} from "./DashboardView"
|
||||||
|
import { messageCatalogQueryOptions, useSaveDashboard } from "./queries"
|
||||||
|
import {
|
||||||
|
INPUT_WIDGETS,
|
||||||
|
WIDGET_LABELS,
|
||||||
|
WIDGET_SIZES,
|
||||||
|
WidgetBody,
|
||||||
|
WidgetFrame,
|
||||||
|
type WidgetKind,
|
||||||
|
} 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",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** How long to sit on edits before saving, so typing is not a save per key. */
|
||||||
|
const AUTOSAVE_MS = 800
|
||||||
|
|
||||||
|
function nextId(dashboard: DashboardDef_Output, type: string): string {
|
||||||
|
const taken = new Set(
|
||||||
|
pagesOf(dashboard).flatMap((page) =>
|
||||||
|
sectionsOf(page).flatMap((section) =>
|
||||||
|
widgetsOf(section).map((widget) => widget.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
let candidate = type
|
||||||
|
for (let i = 2; taken.has(candidate); i++) candidate = `${type}${i}`
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardEditor({
|
||||||
|
dashboard,
|
||||||
|
pageId,
|
||||||
|
}: {
|
||||||
|
dashboard: DashboardDef_Output
|
||||||
|
pageId?: string
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState<DashboardDef_Output>(dashboard)
|
||||||
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
const save = useSaveDashboard(dashboard.name)
|
||||||
|
const { showErrorToast } = useCustomToast()
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
// The saved version is what the next save is based on; without following it
|
||||||
|
// the second save of a session is always a conflict.
|
||||||
|
const version = useRef(dashboard.version)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
version.current = dashboard.version
|
||||||
|
}, [dashboard.version])
|
||||||
|
|
||||||
|
const commit = (next: DashboardDef_Output) => {
|
||||||
|
setDraft(next)
|
||||||
|
if (timer.current) clearTimeout(timer.current)
|
||||||
|
timer.current = setTimeout(() => {
|
||||||
|
save.mutate(
|
||||||
|
{ ...next, version: version.current },
|
||||||
|
{
|
||||||
|
onSuccess: (saved) => {
|
||||||
|
version.current = saved.version
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
handleError.call(showErrorToast, error as ApiError),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}, AUTOSAVE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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 updateWidgets = (next: WidgetDef[]) =>
|
||||||
|
commit({
|
||||||
|
...draft,
|
||||||
|
pages: pagesOf(draft).map((candidate) =>
|
||||||
|
candidate.id !== page.id
|
||||||
|
? candidate
|
||||||
|
: {
|
||||||
|
...candidate,
|
||||||
|
sections: sectionsOf(candidate).map((existing) =>
|
||||||
|
existing.id !== section.id
|
||||||
|
? existing
|
||||||
|
: { ...existing, widgets: next },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const addWidget = (type: WidgetKind) => {
|
||||||
|
const id = nextId(draft, type)
|
||||||
|
updateWidgets([
|
||||||
|
...widgets,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title: WIDGET_LABELS[type],
|
||||||
|
layout: { lg: { x: 0, y: 0, ...WIDGET_SIZES[type] } },
|
||||||
|
config: {},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
setSelected(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = (id: string, changes: Partial<WidgetDef>) =>
|
||||||
|
updateWidgets(
|
||||||
|
widgets.map((widget) =>
|
||||||
|
widget.id === id ? { ...widget, ...changes } : widget,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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 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}
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-h-0 flex-1 text-left"
|
||||||
|
onClick={() => setSelected(widget.id)}
|
||||||
|
>
|
||||||
|
<WidgetBody widget={widget} />
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{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)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(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,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { widgetStyle }
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import type {
|
||||||
|
DashboardDef_Output,
|
||||||
|
PageDef_Output,
|
||||||
|
SectionDef_Output,
|
||||||
|
WidgetDef,
|
||||||
|
} from "@/client"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { WidgetBody, WidgetFrame } 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.
|
||||||
|
*/
|
||||||
|
const GRID = "grid-cols-3 md:grid-cols-6 lg:grid-cols-12"
|
||||||
|
|
||||||
|
/** One grid row, in pixels. Widget heights are multiples of this. */
|
||||||
|
const ROW = "5rem"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The generated client marks every list optional, because the server fills
|
||||||
|
* them in. These three keep that from spreading through the components.
|
||||||
|
*/
|
||||||
|
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 }
|
||||||
|
>
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export function widgetStyle(widget: WidgetDef): React.CSSProperties {
|
||||||
|
const { w = 3, h = 2 } = placement(widget)
|
||||||
|
return {
|
||||||
|
gridColumn: `span ${Math.min(12, Math.max(1, w))}`,
|
||||||
|
gridRow: `span ${Math.max(1, h)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionGrid({
|
||||||
|
section,
|
||||||
|
renderWidget,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
section: SectionDef_Output
|
||||||
|
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="grid gap-3">
|
||||||
|
{section.title ? (
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground">
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={cn("grid gap-3", GRID, className)}
|
||||||
|
style={{ gridAutoRows: ROW }}
|
||||||
|
>
|
||||||
|
{widgetsOf(section).map((widget) => (
|
||||||
|
<div key={widget.id} style={widgetStyle(widget)} className="min-w-0">
|
||||||
|
{renderWidget ? (
|
||||||
|
renderWidget(widget)
|
||||||
|
) : (
|
||||||
|
<WidgetFrame title={widget.title}>
|
||||||
|
<WidgetBody widget={widget} />
|
||||||
|
</WidgetFrame>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardView({
|
||||||
|
dashboard,
|
||||||
|
pageId,
|
||||||
|
renderWidget,
|
||||||
|
}: {
|
||||||
|
dashboard: DashboardDef_Output
|
||||||
|
pageId?: string
|
||||||
|
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
||||||
|
}) {
|
||||||
|
const pages = pagesOf(dashboard)
|
||||||
|
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This dashboard has no pages yet.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const empty = sectionsOf(page).every(
|
||||||
|
(section) => widgetsOf(section).length === 0,
|
||||||
|
)
|
||||||
|
if (empty) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
data-testid="dashboard-empty"
|
||||||
|
>
|
||||||
|
Nothing on this page yet. Edit it to add a widget.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{sectionsOf(page).map((section) => (
|
||||||
|
<SectionGrid
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
renderWidget={renderWidget}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DashboardDef_Input,
|
||||||
|
DashboardsService,
|
||||||
|
MessagesService,
|
||||||
|
} from "@/client"
|
||||||
|
|
||||||
|
export const dashboardKeys = {
|
||||||
|
all: ["dashboards"] as const,
|
||||||
|
detail: (name: string) => ["dashboards", name] as const,
|
||||||
|
messages: ["messages"] as const,
|
||||||
|
history: (message: string) => ["messages", message, "history"] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dashboardsQueryOptions = () => ({
|
||||||
|
queryKey: dashboardKeys.all,
|
||||||
|
queryFn: () => DashboardsService.readDashboards(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const dashboardQueryOptions = (name: string) => ({
|
||||||
|
queryKey: dashboardKeys.detail(name),
|
||||||
|
queryFn: () => DashboardsService.readDashboard({ name }),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Every message any flow declares — what a widget can be pointed at. */
|
||||||
|
export const messageCatalogQueryOptions = () => ({
|
||||||
|
queryKey: dashboardKeys.messages,
|
||||||
|
queryFn: () => MessagesService.readMessages(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const messageHistoryQueryOptions = (message: string) => ({
|
||||||
|
queryKey: dashboardKeys.history(message),
|
||||||
|
queryFn: () => MessagesService.readMessageHistory({ name: message }),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Saving a dashboard, carrying the version it was based on. */
|
||||||
|
export function useSaveDashboard(name: string) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: DashboardDef_Input) =>
|
||||||
|
DashboardsService.saveDashboard({ name, requestBody: body }),
|
||||||
|
onSuccess: (saved) => {
|
||||||
|
queryClient.setQueryData(dashboardKeys.detail(name), saved)
|
||||||
|
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What an input widget does: put a value into the graph. */
|
||||||
|
export function usePublishMessage() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ name, value }: { name: string; value: unknown }) =>
|
||||||
|
MessagesService.publishMessage({ name, requestBody: { value } }),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import type { WidgetDef } from "@/client"
|
||||||
|
import { useLiveValue } from "@/components/Flow/liveStore"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { usePublishMessage } from "./queries"
|
||||||
|
|
||||||
|
/** Widget types that put a value into the graph rather than read one. */
|
||||||
|
export const INPUT_WIDGETS = new Set([
|
||||||
|
"button",
|
||||||
|
"switch",
|
||||||
|
"slider",
|
||||||
|
"input",
|
||||||
|
"dropdown",
|
||||||
|
])
|
||||||
|
|
||||||
|
export type WidgetKind = WidgetDef["type"]
|
||||||
|
|
||||||
|
export const WIDGET_LABELS: Record<WidgetKind, string> = {
|
||||||
|
stat: "Value",
|
||||||
|
gauge: "Gauge",
|
||||||
|
chart: "Chart",
|
||||||
|
markdown: "Text",
|
||||||
|
button: "Button",
|
||||||
|
switch: "Switch",
|
||||||
|
slider: "Slider",
|
||||||
|
input: "Input",
|
||||||
|
dropdown: "Dropdown",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default footprint per type, in grid units. */
|
||||||
|
export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
|
||||||
|
stat: { w: 3, h: 2 },
|
||||||
|
gauge: { w: 3, h: 3 },
|
||||||
|
chart: { w: 6, h: 4 },
|
||||||
|
markdown: { w: 6, h: 2 },
|
||||||
|
button: { w: 3, h: 2 },
|
||||||
|
switch: { w: 3, h: 2 },
|
||||||
|
slider: { w: 4, h: 2 },
|
||||||
|
input: { w: 4, h: 2 },
|
||||||
|
dropdown: { w: 4, h: 2 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function config(widget: WidgetDef): Record<string, unknown> {
|
||||||
|
return (widget.config ?? {}) as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown, fallback = ""): string {
|
||||||
|
return value === null || value === undefined ? fallback : String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(value: unknown, fallback: number): number {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formats a reading the way a panel across the room should read it. */
|
||||||
|
function format(value: unknown, precision: number | null): string {
|
||||||
|
if (value === null || value === undefined) return "—"
|
||||||
|
if (typeof value === "boolean") return value ? "On" : "Off"
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return precision === null ? String(value) : value.toFixed(precision)
|
||||||
|
}
|
||||||
|
if (typeof value === "object") return JSON.stringify(value)
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frame every widget sits in.
|
||||||
|
*
|
||||||
|
* A card rather than floating chrome: a dashboard is content, and the panels
|
||||||
|
* that float are the ones that sit over something.
|
||||||
|
*/
|
||||||
|
export function WidgetFrame({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
actions?: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-full flex-col gap-2 overflow-hidden rounded-lg border border-border bg-card p-4 shadow-e1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title || actions ? (
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
{title ? (
|
||||||
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col justify-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Unbound() {
|
||||||
|
return <p className="text-sm text-muted-foreground">Pick a message.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Display
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StatWidget({ widget }: { widget: WidgetDef }) {
|
||||||
|
const cfg = config(widget)
|
||||||
|
const message = text(cfg.message)
|
||||||
|
const live = useLiveValue(message || undefined)
|
||||||
|
if (!message) return <Unbound />
|
||||||
|
|
||||||
|
const precision = cfg.precision === undefined ? null : num(cfg.precision, 1)
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="truncate text-3xl tabular-nums">
|
||||||
|
{format(live?.value, precision)}
|
||||||
|
</span>
|
||||||
|
{cfg.unit ? (
|
||||||
|
<span className="text-base text-muted-foreground">
|
||||||
|
{text(cfg.unit)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dial, drawn as an arc.
|
||||||
|
*
|
||||||
|
* The number is always written out as well: a reading that only exists as an
|
||||||
|
* angle is unreadable to anyone who cannot judge one.
|
||||||
|
*/
|
||||||
|
function GaugeWidget({ widget }: { widget: WidgetDef }) {
|
||||||
|
const cfg = config(widget)
|
||||||
|
const message = text(cfg.message)
|
||||||
|
const live = useLiveValue(message || undefined)
|
||||||
|
if (!message) return <Unbound />
|
||||||
|
|
||||||
|
const min = num(cfg.min, 0)
|
||||||
|
const max = num(cfg.max, 100)
|
||||||
|
const value = typeof live?.value === "number" ? live.value : null
|
||||||
|
const fraction =
|
||||||
|
value === null
|
||||||
|
? 0
|
||||||
|
: Math.min(1, Math.max(0, (value - min) / (max - min || 1)))
|
||||||
|
|
||||||
|
// A 240° arc, the shape a dial is expected to have.
|
||||||
|
const radius = 42
|
||||||
|
const sweep = 240
|
||||||
|
const start = 150
|
||||||
|
const point = (angle: number) => {
|
||||||
|
const radians = (angle * Math.PI) / 180
|
||||||
|
return [50 + radius * Math.cos(radians), 50 + radius * Math.sin(radians)]
|
||||||
|
}
|
||||||
|
const arc = (from: number, to: number) => {
|
||||||
|
const [x1, y1] = point(from)
|
||||||
|
const [x2, y2] = point(to)
|
||||||
|
const large = Math.abs(to - from) > 180 ? 1 : 0
|
||||||
|
return `M ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 78"
|
||||||
|
className="h-full max-h-full w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label={`${format(value, 1)} of ${max}`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d={arc(start, start + sweep)}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--muted)"
|
||||||
|
strokeWidth={9}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{fraction > 0 ? (
|
||||||
|
<path
|
||||||
|
d={arc(start, start + sweep * fraction)}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--primary)"
|
||||||
|
strokeWidth={9}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<text
|
||||||
|
x={50}
|
||||||
|
y={54}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="fill-foreground text-[13px] tabular-nums"
|
||||||
|
>
|
||||||
|
{format(
|
||||||
|
value,
|
||||||
|
cfg.precision === undefined ? 1 : num(cfg.precision, 1),
|
||||||
|
)}
|
||||||
|
{cfg.unit ? text(cfg.unit) : ""}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A very small markdown subset: headings, bold, code, links, list items.
|
||||||
|
*
|
||||||
|
* Enough for the labels and notes a dashboard carries, and not worth a parser.
|
||||||
|
*/
|
||||||
|
function MarkdownWidget({ widget }: { widget: WidgetDef }) {
|
||||||
|
const content = text(config(widget).content)
|
||||||
|
const lines = content.split("\n")
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1 text-sm">
|
||||||
|
{lines.map((line, index) => {
|
||||||
|
const heading = /^(#{1,3})\s+(.*)$/.exec(line)
|
||||||
|
const body = heading ? heading[2] : line.replace(/^[-*]\s+/, "")
|
||||||
|
const bullet = !heading && /^[-*]\s+/.test(line)
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
// Plain text: position is the only identity a line has.
|
||||||
|
key={`line-${index}`}
|
||||||
|
className={cn(
|
||||||
|
heading?.[1] === "#" && "text-lg font-medium",
|
||||||
|
heading?.[1] === "##" && "font-medium",
|
||||||
|
heading?.[1] === "###" && "text-muted-foreground",
|
||||||
|
bullet && "pl-4",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{bullet ? "• " : ""}
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Input
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Publishing, with the value shown as sent until the engine confirms it. */
|
||||||
|
function usePublish(widget: WidgetDef) {
|
||||||
|
const cfg = config(widget)
|
||||||
|
const target = text(cfg.target)
|
||||||
|
const publish = usePublishMessage()
|
||||||
|
const live = useLiveValue(target || undefined)
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
live,
|
||||||
|
send: (value: unknown) => {
|
||||||
|
if (!target) return
|
||||||
|
publish.mutate({ name: target, value })
|
||||||
|
},
|
||||||
|
pending: publish.isPending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonWidget({ widget }: { widget: WidgetDef }) {
|
||||||
|
const cfg = config(widget)
|
||||||
|
const { target, send, pending } = usePublish(widget)
|
||||||
|
if (!target) return <Unbound />
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => send(cfg.value ?? true)}
|
||||||
|
>
|
||||||
|
{text(cfg.label, widget.title || "Send")}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SwitchWidget({ widget }: { widget: WidgetDef }) {
|
||||||
|
const { target, live, send } = usePublish(widget)
|
||||||
|
if (!target) return <Unbound />
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm">{live?.value === true ? "On" : "Off"}</span>
|
||||||
|
<Switch
|
||||||
|
checked={live?.value === true}
|
||||||
|
aria-label={widget.title || target}
|
||||||
|
onCheckedChange={(checked) => send(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SliderWidget({ widget }: { widget: WidgetDef }) {
|
||||||
|
const cfg = config(widget)
|
||||||
|
const { target, live, send } = usePublish(widget)
|
||||||
|
const min = num(cfg.min, 0)
|
||||||
|
const max = num(cfg.max, 100)
|
||||||
|
const step = num(cfg.step, 1)
|
||||||
|
// While dragging, the handle follows the finger rather than the engine.
|
||||||
|
const [dragging, setDragging] = useState<number | null>(null)
|
||||||
|
if (!target) return <Unbound />
|
||||||
|
|
||||||
|
const current =
|
||||||
|
dragging ?? (typeof live?.value === "number" ? live.value : min)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<span className="text-2xl tabular-nums">{current}</span>
|
||||||
|
{cfg.unit ? (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{text(cfg.unit)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={current}
|
||||||
|
aria-label={widget.title || target}
|
||||||
|
className="h-11 w-full accent-[var(--primary)] md:h-8"
|
||||||
|
onChange={(event) => setDragging(Number(event.target.value))}
|
||||||
|
// Only the release publishes: dragging would otherwise send a value
|
||||||
|
// per pixel and flood whatever is listening.
|
||||||
|
onPointerUp={() => {
|
||||||
|
if (dragging !== null) send(dragging)
|
||||||
|
setDragging(null)
|
||||||
|
}}
|
||||||
|
onKeyUp={() => {
|
||||||
|
if (dragging !== null) send(dragging)
|
||||||
|
setDragging(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputWidget({ widget }: { widget: WidgetDef }) {
|
||||||
|
const cfg = config(widget)
|
||||||
|
const { target, live, send } = usePublish(widget)
|
||||||
|
const [draft, setDraft] = useState<string | null>(null)
|
||||||
|
if (!target) return <Unbound />
|
||||||
|
|
||||||
|
const asNumber = cfg.dtype === "float" || cfg.dtype === "int"
|
||||||
|
const commit = () => {
|
||||||
|
if (draft === null) return
|
||||||
|
send(asNumber ? Number(draft) || 0 : draft)
|
||||||
|
setDraft(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={draft ?? text(live?.value)}
|
||||||
|
type={asNumber ? "number" : "text"}
|
||||||
|
aria-label={widget.title || target}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") commit()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownWidget({ widget }: { widget: WidgetDef }) {
|
||||||
|
const cfg = config(widget)
|
||||||
|
const { target, live, send } = usePublish(widget)
|
||||||
|
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
||||||
|
if (!target) return <Unbound />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label className="sr-only">{widget.title || target}</Label>
|
||||||
|
<Select
|
||||||
|
value={text(live?.value)}
|
||||||
|
onValueChange={(value) => send(asOriginal(value, options))}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label={widget.title || target}>
|
||||||
|
<SelectValue placeholder="Choose" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={text(option.value)} value={text(option.value)}>
|
||||||
|
{option.label ?? text(option.value)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Radix hands back a string; the message wants whatever was configured. */
|
||||||
|
function asOriginal(selected: string, options: { value?: unknown }[]): unknown {
|
||||||
|
const match = options.find((option) => text(option.value) === selected)
|
||||||
|
return match ? match.value : selected
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const RENDERERS: Partial<
|
||||||
|
Record<WidgetKind, (props: { widget: WidgetDef }) => React.ReactNode>
|
||||||
|
> = {
|
||||||
|
stat: StatWidget,
|
||||||
|
gauge: GaugeWidget,
|
||||||
|
markdown: MarkdownWidget,
|
||||||
|
button: ButtonWidget,
|
||||||
|
switch: SwitchWidget,
|
||||||
|
slider: SliderWidget,
|
||||||
|
input: InputWidget,
|
||||||
|
dropdown: DropdownWidget,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidgetBody({ widget }: { widget: WidgetDef }) {
|
||||||
|
const Renderer = RENDERERS[widget.type]
|
||||||
|
if (!Renderer) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{WIDGET_LABELS[widget.type]} widgets are not drawn yet.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <Renderer widget={widget} />
|
||||||
|
}
|
||||||
@@ -978,10 +978,6 @@ export function FlowEditor({ flowName }: { flowName: string }) {
|
|||||||
|
|
||||||
useFlowSocket(onAuthFailure)
|
useFlowSocket(onAuthFailure)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => liveStore.reset()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
{/*
|
{/*
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { type LogLine, liveStore } from "./liveStore"
|
|||||||
import { flowKeys } from "./queries"
|
import { flowKeys } from "./queries"
|
||||||
|
|
||||||
const RECONNECT_MIN = 1000
|
const RECONNECT_MIN = 1000
|
||||||
|
|
||||||
|
/** How many components want the socket open. */
|
||||||
|
let mounted = 0
|
||||||
const RECONNECT_MAX = 30000
|
const RECONNECT_MAX = 30000
|
||||||
|
|
||||||
type FlowEvent =
|
type FlowEvent =
|
||||||
@@ -52,6 +55,14 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// The editor and a dashboard can both be mounted; one socket serves both,
|
||||||
|
// and the second caller just rides along.
|
||||||
|
mounted += 1
|
||||||
|
if (mounted > 1) {
|
||||||
|
return () => {
|
||||||
|
mounted -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
closed.current = false
|
closed.current = false
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
@@ -126,6 +137,7 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
|
|||||||
connect()
|
connect()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
mounted -= 1
|
||||||
closed.current = true
|
closed.current = true
|
||||||
if (timer.current) clearTimeout(timer.current)
|
if (timer.current) clearTimeout(timer.current)
|
||||||
socket.current?.close()
|
socket.current?.close()
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Home, LogOut, Settings, Users, Workflow } from "lucide-react"
|
import {
|
||||||
|
Home,
|
||||||
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
Workflow,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
import { Logo } from "@/components/Common/Logo"
|
import { Logo } from "@/components/Common/Logo"
|
||||||
import {
|
import {
|
||||||
@@ -12,8 +19,10 @@ import useAuth from "@/hooks/useAuth"
|
|||||||
import { type Item, Main } from "./Main"
|
import { type Item, Main } from "./Main"
|
||||||
|
|
||||||
const baseItems: Item[] = [
|
const baseItems: Item[] = [
|
||||||
{ icon: Home, title: "Dashboard", path: "/" },
|
// "Home" rather than "Dashboard": dashboards are their own thing now.
|
||||||
|
{ icon: Home, title: "Home", path: "/" },
|
||||||
{ icon: Workflow, title: "Flows", path: "/flows" },
|
{ icon: Workflow, title: "Flows", path: "/flows" },
|
||||||
|
{ icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type UserRegister,
|
type UserRegister,
|
||||||
UsersService,
|
UsersService,
|
||||||
} from "@/client"
|
} from "@/client"
|
||||||
|
import { liveStore } from "@/components/Flow/liveStore"
|
||||||
import { handleError } from "@/utils"
|
import { handleError } from "@/utils"
|
||||||
import useCustomToast from "./useCustomToast"
|
import useCustomToast from "./useCustomToast"
|
||||||
|
|
||||||
@@ -68,6 +69,8 @@ const useAuth = () => {
|
|||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem("access_token")
|
localStorage.removeItem("access_token")
|
||||||
|
// Live values belong to the session that was watching them.
|
||||||
|
liveStore.reset()
|
||||||
navigate({ to: "/login" })
|
navigate({ to: "/login" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
|||||||
import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize'
|
import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize'
|
||||||
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
||||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
||||||
|
import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/index'
|
||||||
import { Route as CanvasFlowsIndexRouteImport } from './routes/_canvas/flows/index'
|
import { Route as CanvasFlowsIndexRouteImport } from './routes/_canvas/flows/index'
|
||||||
|
import { Route as LayoutDashboardsNameRouteImport } from './routes/_layout/dashboards/$name'
|
||||||
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
|
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
|
||||||
|
|
||||||
const SignupRoute = SignupRouteImport.update({
|
const SignupRoute = SignupRouteImport.update({
|
||||||
@@ -70,11 +72,21 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
|||||||
path: '/admin',
|
path: '/admin',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const LayoutDashboardsIndexRoute = LayoutDashboardsIndexRouteImport.update({
|
||||||
|
id: '/dashboards/',
|
||||||
|
path: '/dashboards/',
|
||||||
|
getParentRoute: () => LayoutRoute,
|
||||||
|
} as any)
|
||||||
const CanvasFlowsIndexRoute = CanvasFlowsIndexRouteImport.update({
|
const CanvasFlowsIndexRoute = CanvasFlowsIndexRouteImport.update({
|
||||||
id: '/flows/',
|
id: '/flows/',
|
||||||
path: '/flows/',
|
path: '/flows/',
|
||||||
getParentRoute: () => CanvasRoute,
|
getParentRoute: () => CanvasRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const LayoutDashboardsNameRoute = LayoutDashboardsNameRouteImport.update({
|
||||||
|
id: '/dashboards/$name',
|
||||||
|
path: '/dashboards/$name',
|
||||||
|
getParentRoute: () => LayoutRoute,
|
||||||
|
} as any)
|
||||||
const CanvasFlowsFlowNameRoute = CanvasFlowsFlowNameRouteImport.update({
|
const CanvasFlowsFlowNameRoute = CanvasFlowsFlowNameRouteImport.update({
|
||||||
id: '/flows/$flowName',
|
id: '/flows/$flowName',
|
||||||
path: '/flows/$flowName',
|
path: '/flows/$flowName',
|
||||||
@@ -91,7 +103,9 @@ export interface FileRoutesByFullPath {
|
|||||||
'/settings': typeof LayoutSettingsRoute
|
'/settings': typeof LayoutSettingsRoute
|
||||||
'/oauth/authorize': typeof OauthAuthorizeRoute
|
'/oauth/authorize': typeof OauthAuthorizeRoute
|
||||||
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||||
|
'/dashboards/$name': typeof LayoutDashboardsNameRoute
|
||||||
'/flows/': typeof CanvasFlowsIndexRoute
|
'/flows/': typeof CanvasFlowsIndexRoute
|
||||||
|
'/dashboards/': typeof LayoutDashboardsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof LayoutIndexRoute
|
'/': typeof LayoutIndexRoute
|
||||||
@@ -103,7 +117,9 @@ export interface FileRoutesByTo {
|
|||||||
'/settings': typeof LayoutSettingsRoute
|
'/settings': typeof LayoutSettingsRoute
|
||||||
'/oauth/authorize': typeof OauthAuthorizeRoute
|
'/oauth/authorize': typeof OauthAuthorizeRoute
|
||||||
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||||
|
'/dashboards/$name': typeof LayoutDashboardsNameRoute
|
||||||
'/flows': typeof CanvasFlowsIndexRoute
|
'/flows': typeof CanvasFlowsIndexRoute
|
||||||
|
'/dashboards': typeof LayoutDashboardsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -118,7 +134,9 @@ export interface FileRoutesById {
|
|||||||
'/oauth/authorize': typeof OauthAuthorizeRoute
|
'/oauth/authorize': typeof OauthAuthorizeRoute
|
||||||
'/_layout/': typeof LayoutIndexRoute
|
'/_layout/': typeof LayoutIndexRoute
|
||||||
'/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
'/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||||
|
'/_layout/dashboards/$name': typeof LayoutDashboardsNameRoute
|
||||||
'/_canvas/flows/': typeof CanvasFlowsIndexRoute
|
'/_canvas/flows/': typeof CanvasFlowsIndexRoute
|
||||||
|
'/_layout/dashboards/': typeof LayoutDashboardsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -132,7 +150,9 @@ export interface FileRouteTypes {
|
|||||||
| '/settings'
|
| '/settings'
|
||||||
| '/oauth/authorize'
|
| '/oauth/authorize'
|
||||||
| '/flows/$flowName'
|
| '/flows/$flowName'
|
||||||
|
| '/dashboards/$name'
|
||||||
| '/flows/'
|
| '/flows/'
|
||||||
|
| '/dashboards/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
@@ -144,7 +164,9 @@ export interface FileRouteTypes {
|
|||||||
| '/settings'
|
| '/settings'
|
||||||
| '/oauth/authorize'
|
| '/oauth/authorize'
|
||||||
| '/flows/$flowName'
|
| '/flows/$flowName'
|
||||||
|
| '/dashboards/$name'
|
||||||
| '/flows'
|
| '/flows'
|
||||||
|
| '/dashboards'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_canvas'
|
| '/_canvas'
|
||||||
@@ -158,7 +180,9 @@ export interface FileRouteTypes {
|
|||||||
| '/oauth/authorize'
|
| '/oauth/authorize'
|
||||||
| '/_layout/'
|
| '/_layout/'
|
||||||
| '/_canvas/flows/$flowName'
|
| '/_canvas/flows/$flowName'
|
||||||
|
| '/_layout/dashboards/$name'
|
||||||
| '/_canvas/flows/'
|
| '/_canvas/flows/'
|
||||||
|
| '/_layout/dashboards/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -243,6 +267,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LayoutAdminRouteImport
|
preLoaderRoute: typeof LayoutAdminRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
|
'/_layout/dashboards/': {
|
||||||
|
id: '/_layout/dashboards/'
|
||||||
|
path: '/dashboards'
|
||||||
|
fullPath: '/dashboards/'
|
||||||
|
preLoaderRoute: typeof LayoutDashboardsIndexRouteImport
|
||||||
|
parentRoute: typeof LayoutRoute
|
||||||
|
}
|
||||||
'/_canvas/flows/': {
|
'/_canvas/flows/': {
|
||||||
id: '/_canvas/flows/'
|
id: '/_canvas/flows/'
|
||||||
path: '/flows'
|
path: '/flows'
|
||||||
@@ -250,6 +281,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof CanvasFlowsIndexRouteImport
|
preLoaderRoute: typeof CanvasFlowsIndexRouteImport
|
||||||
parentRoute: typeof CanvasRoute
|
parentRoute: typeof CanvasRoute
|
||||||
}
|
}
|
||||||
|
'/_layout/dashboards/$name': {
|
||||||
|
id: '/_layout/dashboards/$name'
|
||||||
|
path: '/dashboards/$name'
|
||||||
|
fullPath: '/dashboards/$name'
|
||||||
|
preLoaderRoute: typeof LayoutDashboardsNameRouteImport
|
||||||
|
parentRoute: typeof LayoutRoute
|
||||||
|
}
|
||||||
'/_canvas/flows/$flowName': {
|
'/_canvas/flows/$flowName': {
|
||||||
id: '/_canvas/flows/$flowName'
|
id: '/_canvas/flows/$flowName'
|
||||||
path: '/flows/$flowName'
|
path: '/flows/$flowName'
|
||||||
@@ -277,12 +315,16 @@ interface LayoutRouteChildren {
|
|||||||
LayoutAdminRoute: typeof LayoutAdminRoute
|
LayoutAdminRoute: typeof LayoutAdminRoute
|
||||||
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
||||||
LayoutIndexRoute: typeof LayoutIndexRoute
|
LayoutIndexRoute: typeof LayoutIndexRoute
|
||||||
|
LayoutDashboardsNameRoute: typeof LayoutDashboardsNameRoute
|
||||||
|
LayoutDashboardsIndexRoute: typeof LayoutDashboardsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayoutRouteChildren: LayoutRouteChildren = {
|
const LayoutRouteChildren: LayoutRouteChildren = {
|
||||||
LayoutAdminRoute: LayoutAdminRoute,
|
LayoutAdminRoute: LayoutAdminRoute,
|
||||||
LayoutSettingsRoute: LayoutSettingsRoute,
|
LayoutSettingsRoute: LayoutSettingsRoute,
|
||||||
LayoutIndexRoute: LayoutIndexRoute,
|
LayoutIndexRoute: LayoutIndexRoute,
|
||||||
|
LayoutDashboardsNameRoute: LayoutDashboardsNameRoute,
|
||||||
|
LayoutDashboardsIndexRoute: LayoutDashboardsIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayoutRouteWithChildren =
|
const LayoutRouteWithChildren =
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
|
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
|
||||||
|
|
||||||
import { Footer } from "@/components/Common/Footer"
|
import { Footer } from "@/components/Common/Footer"
|
||||||
|
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||||
import AppSidebar from "@/components/Sidebar/AppSidebar"
|
import AppSidebar from "@/components/Sidebar/AppSidebar"
|
||||||
import {
|
import {
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
@@ -21,6 +22,10 @@ export const Route = createFileRoute("/_layout")({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
|
// Dashboards live in this shell and read live values, so the socket belongs
|
||||||
|
// here rather than only inside the editor.
|
||||||
|
useFlowSocket()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider className="bg-card">
|
<SidebarProvider className="bg-card">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
|
||||||
|
import { LayoutDashboard, Plus } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import { DashboardsService } from "@/client"
|
||||||
|
import {
|
||||||
|
dashboardKeys,
|
||||||
|
dashboardsQueryOptions,
|
||||||
|
} from "@/components/Dashboard/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/dashboards/")({
|
||||||
|
component: Dashboards,
|
||||||
|
})
|
||||||
|
|
||||||
|
function Dashboards() {
|
||||||
|
const { data } = useQuery(dashboardsQueryOptions())
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { showErrorToast } = useCustomToast()
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: (dashboard: string) =>
|
||||||
|
DashboardsService.createDashboard({ name: dashboard }),
|
||||||
|
onSuccess: (created) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
|
||||||
|
navigate({
|
||||||
|
to: "/dashboards/$name",
|
||||||
|
params: { name: created.name },
|
||||||
|
search: { edit: true },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
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, "_")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<h1 className="text-2xl">Dashboards</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
What a wall panel shows, built from the messages your flows carry.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="flex max-w-md items-center gap-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (slug) create.mutate(slug)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{dashboards.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No dashboards yet. Name one above to start.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{dashboards.map((dashboard) => (
|
||||||
|
<Link
|
||||||
|
key={dashboard.name}
|
||||||
|
to="/dashboards/$name"
|
||||||
|
params={{ name: dashboard.name }}
|
||||||
|
className="grid gap-1 rounded-lg border border-border bg-card p-4 shadow-e1 transition-colors hover:bg-accent/50"
|
||||||
|
data-testid="dashboard-card"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<LayoutDashboard className="size-4 text-muted-foreground" />
|
||||||
|
{dashboard.title || dashboard.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{dashboard.widget_count} widget
|
||||||
|
{dashboard.widget_count === 1 ? "" : "s"} ·{" "}
|
||||||
|
{dashboard.page_count} page
|
||||||
|
{dashboard.page_count === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user