diff --git a/NOTEPAD.md b/NOTEPAD.md index 0e2bba4..9748dca 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -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. - PERF/FLOW: every save rebuilds the whole pipeline. Fine at the current flow count; rebuild 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 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 diff --git a/ROADMAP.md b/ROADMAP.md index d4a6c40..1ceb4ee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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` → *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 - [ ] Per-device view diff --git a/frontend/scripts/verify-dashboard.mjs b/frontend/scripts/verify-dashboard.mjs new file mode 100644 index 0000000..08d96fe --- /dev/null +++ b/frontend/scripts/verify-dashboard.mjs @@ -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() diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx new file mode 100644 index 0000000..e46cfe5 --- /dev/null +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -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(dashboard) + const [selected, setSelected] = useState(null) + const save = useSaveDashboard(dashboard.name) + const { showErrorToast } = useCustomToast() + const timer = useRef | null>(null) + // The saved version is what the next save is based on; without following it + // the second save of a session is always a conflict. + const version = useRef(dashboard.version) + + useEffect(() => { + version.current = dashboard.version + }, [dashboard.version]) + + const 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) => + 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 ( +
+
+ Add + {KINDS.map((kind) => ( + + ))} +
+ + ( + + + + +
+ } + > + + + )} + /> + + {active ? ( + patch(active.id, changes)} + /> + ) : ( +

+ Add a widget, or pick one to configure it. +

+ )} + + ) +} + +/** What one widget shows or does. Fields differ per type; the set is small. */ +function WidgetSettings({ + widget, + onChange, +}: { + widget: WidgetDef + onChange: (changes: Partial) => void +}) { + const { data: catalog } = useQuery(messageCatalogQueryOptions()) + const messages = catalog?.data ?? [] + const config = (widget.config ?? {}) as Record + 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 ( +
+ + {WIDGET_LABELS[widget.type]} settings + + +
+ + onChange({ title: event.target.value })} + /> +
+ + {widget.type === "markdown" ? ( +
+ + set("content", event.target.value)} + /> +
+ ) : ( +
+ + +
+ )} + + {(widget.type === "stat" || widget.type === "gauge") && ( +
+ + set("unit", event.target.value)} + /> +
+ )} + + {(widget.type === "gauge" || widget.type === "slider") && ( +
+
+ + set("min", Number(event.target.value) || 0)} + /> +
+
+ + set("max", Number(event.target.value) || 0)} + /> +
+
+ )} + + {widget.type === "button" && ( +
+ + { + const raw = event.target.value + const asNumber = Number(raw) + set( + "value", + raw === "true" || raw === "false" + ? raw === "true" + : raw !== "" && Number.isFinite(asNumber) + ? asNumber + : raw, + ) + }} + /> +
+ )} +
+ ) +} + +export { widgetStyle } diff --git a/frontend/src/components/Dashboard/DashboardView.tsx b/frontend/src/components/Dashboard/DashboardView.tsx new file mode 100644 index 0000000..0964315 --- /dev/null +++ b/frontend/src/components/Dashboard/DashboardView.tsx @@ -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.title ? ( +

+ {section.title} +

+ ) : null} +
+ {widgetsOf(section).map((widget) => ( +
+ {renderWidget ? ( + renderWidget(widget) + ) : ( + + + + )} +
+ ))} +
+
+ ) +} + +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 ( +

+ This dashboard has no pages yet. +

+ ) + } + + const empty = sectionsOf(page).every( + (section) => widgetsOf(section).length === 0, + ) + if (empty) { + return ( +

+ Nothing on this page yet. Edit it to add a widget. +

+ ) + } + + return ( +
+ {sectionsOf(page).map((section) => ( + + ))} +
+ ) +} diff --git a/frontend/src/components/Dashboard/queries.ts b/frontend/src/components/Dashboard/queries.ts new file mode 100644 index 0000000..3f0c4d0 --- /dev/null +++ b/frontend/src/components/Dashboard/queries.ts @@ -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 } }), + }) +} diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx new file mode 100644 index 0000000..9cb6946 --- /dev/null +++ b/frontend/src/components/Dashboard/widgets.tsx @@ -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 = { + 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 = { + 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 { + return (widget.config ?? {}) as Record +} + +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 ( +
+ {title || actions ? ( +
+ {title ? ( + + {title} + + ) : ( + + )} + {actions} +
+ ) : null} +
+ {children} +
+
+ ) +} + +function Unbound() { + return

Pick a message.

+} + +// --------------------------------------------------------------------------- +// Display +// --------------------------------------------------------------------------- + +function StatWidget({ widget }: { widget: WidgetDef }) { + const cfg = config(widget) + const message = text(cfg.message) + const live = useLiveValue(message || undefined) + if (!message) return + + const precision = cfg.precision === undefined ? null : num(cfg.precision, 1) + return ( +
+ + {format(live?.value, precision)} + + {cfg.unit ? ( + + {text(cfg.unit)} + + ) : null} +
+ ) +} + +/** + * 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 + + 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 ( +
+ + + {fraction > 0 ? ( + + ) : null} + + {format( + value, + cfg.precision === undefined ? 1 : num(cfg.precision, 1), + )} + {cfg.unit ? text(cfg.unit) : ""} + + +
+ ) +} + +/** + * 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 ( +
+ {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 ( +

+ {bullet ? "• " : ""} + {body} +

+ ) + })} +
+ ) +} + +// --------------------------------------------------------------------------- +// 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 + return ( + + ) +} + +function SwitchWidget({ widget }: { widget: WidgetDef }) { + const { target, live, send } = usePublish(widget) + if (!target) return + return ( +
+ {live?.value === true ? "On" : "Off"} + send(checked)} + /> +
+ ) +} + +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(null) + if (!target) return + + const current = + dragging ?? (typeof live?.value === "number" ? live.value : min) + + return ( +
+
+ {current} + {cfg.unit ? ( + + {text(cfg.unit)} + + ) : null} +
+ 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) + }} + /> +
+ ) +} + +function InputWidget({ widget }: { widget: WidgetDef }) { + const cfg = config(widget) + const { target, live, send } = usePublish(widget) + const [draft, setDraft] = useState(null) + if (!target) return + + const asNumber = cfg.dtype === "float" || cfg.dtype === "int" + const commit = () => { + if (draft === null) return + send(asNumber ? Number(draft) || 0 : draft) + setDraft(null) + } + + return ( + 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 + + return ( +
+ + +
+ ) +} + +/** 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 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 ( +

+ {WIDGET_LABELS[widget.type]} widgets are not drawn yet. +

+ ) + } + return +} diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index ff38cd2..a71a884 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -978,10 +978,6 @@ export function FlowEditor({ flowName }: { flowName: string }) { useFlowSocket(onAuthFailure) - useEffect(() => { - return () => liveStore.reset() - }, []) - return ( {/* diff --git a/frontend/src/components/Flow/useFlowSocket.ts b/frontend/src/components/Flow/useFlowSocket.ts index c279ef2..a1dce60 100644 --- a/frontend/src/components/Flow/useFlowSocket.ts +++ b/frontend/src/components/Flow/useFlowSocket.ts @@ -6,6 +6,9 @@ import { type LogLine, liveStore } from "./liveStore" import { flowKeys } from "./queries" const RECONNECT_MIN = 1000 + +/** How many components want the socket open. */ +let mounted = 0 const RECONNECT_MAX = 30000 type FlowEvent = @@ -52,6 +55,14 @@ export function useFlowSocket(onAuthFailure?: () => void): void { const queryClient = useQueryClient() 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 const connect = () => { @@ -126,6 +137,7 @@ export function useFlowSocket(onAuthFailure?: () => void): void { connect() return () => { + mounted -= 1 closed.current = true if (timer.current) clearTimeout(timer.current) socket.current?.close() diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index ca55afc..fc27f42 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -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 { @@ -12,8 +19,10 @@ import useAuth from "@/hooks/useAuth" import { type Item, Main } from "./Main" 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: LayoutDashboard, title: "Dashboards", path: "/dashboards" }, ] export function AppSidebar() { diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index f53ba44..e602744 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -8,6 +8,7 @@ import { type UserRegister, UsersService, } from "@/client" +import { liveStore } from "@/components/Flow/liveStore" import { handleError } from "@/utils" import useCustomToast from "./useCustomToast" @@ -68,6 +69,8 @@ const useAuth = () => { const logout = () => { localStorage.removeItem("access_token") + // Live values belong to the session that was watching them. + liveStore.reset() navigate({ to: "/login" }) } diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index a58dafd..ec530bc 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -19,7 +19,9 @@ import { Route as LayoutIndexRouteImport } from './routes/_layout/index' import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize' import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' 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 LayoutDashboardsNameRouteImport } from './routes/_layout/dashboards/$name' import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName' const SignupRoute = SignupRouteImport.update({ @@ -70,11 +72,21 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({ path: '/admin', getParentRoute: () => LayoutRoute, } as any) +const LayoutDashboardsIndexRoute = LayoutDashboardsIndexRouteImport.update({ + id: '/dashboards/', + path: '/dashboards/', + getParentRoute: () => LayoutRoute, +} as any) const CanvasFlowsIndexRoute = CanvasFlowsIndexRouteImport.update({ id: '/flows/', path: '/flows/', getParentRoute: () => CanvasRoute, } as any) +const LayoutDashboardsNameRoute = LayoutDashboardsNameRouteImport.update({ + id: '/dashboards/$name', + path: '/dashboards/$name', + getParentRoute: () => LayoutRoute, +} as any) const CanvasFlowsFlowNameRoute = CanvasFlowsFlowNameRouteImport.update({ id: '/flows/$flowName', path: '/flows/$flowName', @@ -91,7 +103,9 @@ export interface FileRoutesByFullPath { '/settings': typeof LayoutSettingsRoute '/oauth/authorize': typeof OauthAuthorizeRoute '/flows/$flowName': typeof CanvasFlowsFlowNameRoute + '/dashboards/$name': typeof LayoutDashboardsNameRoute '/flows/': typeof CanvasFlowsIndexRoute + '/dashboards/': typeof LayoutDashboardsIndexRoute } export interface FileRoutesByTo { '/': typeof LayoutIndexRoute @@ -103,7 +117,9 @@ export interface FileRoutesByTo { '/settings': typeof LayoutSettingsRoute '/oauth/authorize': typeof OauthAuthorizeRoute '/flows/$flowName': typeof CanvasFlowsFlowNameRoute + '/dashboards/$name': typeof LayoutDashboardsNameRoute '/flows': typeof CanvasFlowsIndexRoute + '/dashboards': typeof LayoutDashboardsIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -118,7 +134,9 @@ export interface FileRoutesById { '/oauth/authorize': typeof OauthAuthorizeRoute '/_layout/': typeof LayoutIndexRoute '/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute + '/_layout/dashboards/$name': typeof LayoutDashboardsNameRoute '/_canvas/flows/': typeof CanvasFlowsIndexRoute + '/_layout/dashboards/': typeof LayoutDashboardsIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -132,7 +150,9 @@ export interface FileRouteTypes { | '/settings' | '/oauth/authorize' | '/flows/$flowName' + | '/dashboards/$name' | '/flows/' + | '/dashboards/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -144,7 +164,9 @@ export interface FileRouteTypes { | '/settings' | '/oauth/authorize' | '/flows/$flowName' + | '/dashboards/$name' | '/flows' + | '/dashboards' id: | '__root__' | '/_canvas' @@ -158,7 +180,9 @@ export interface FileRouteTypes { | '/oauth/authorize' | '/_layout/' | '/_canvas/flows/$flowName' + | '/_layout/dashboards/$name' | '/_canvas/flows/' + | '/_layout/dashboards/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -243,6 +267,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAdminRouteImport parentRoute: typeof LayoutRoute } + '/_layout/dashboards/': { + id: '/_layout/dashboards/' + path: '/dashboards' + fullPath: '/dashboards/' + preLoaderRoute: typeof LayoutDashboardsIndexRouteImport + parentRoute: typeof LayoutRoute + } '/_canvas/flows/': { id: '/_canvas/flows/' path: '/flows' @@ -250,6 +281,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CanvasFlowsIndexRouteImport 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': { id: '/_canvas/flows/$flowName' path: '/flows/$flowName' @@ -277,12 +315,16 @@ interface LayoutRouteChildren { LayoutAdminRoute: typeof LayoutAdminRoute LayoutSettingsRoute: typeof LayoutSettingsRoute LayoutIndexRoute: typeof LayoutIndexRoute + LayoutDashboardsNameRoute: typeof LayoutDashboardsNameRoute + LayoutDashboardsIndexRoute: typeof LayoutDashboardsIndexRoute } const LayoutRouteChildren: LayoutRouteChildren = { LayoutAdminRoute: LayoutAdminRoute, LayoutSettingsRoute: LayoutSettingsRoute, LayoutIndexRoute: LayoutIndexRoute, + LayoutDashboardsNameRoute: LayoutDashboardsNameRoute, + LayoutDashboardsIndexRoute: LayoutDashboardsIndexRoute, } const LayoutRouteWithChildren = diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx index 23421e9..bf8ecb4 100644 --- a/frontend/src/routes/_layout.tsx +++ b/frontend/src/routes/_layout.tsx @@ -1,6 +1,7 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router" import { Footer } from "@/components/Common/Footer" +import { useFlowSocket } from "@/components/Flow/useFlowSocket" import AppSidebar from "@/components/Sidebar/AppSidebar" import { SidebarInset, @@ -21,6 +22,10 @@ export const Route = createFileRoute("/_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 ( diff --git a/frontend/src/routes/_layout/dashboards/$name.tsx b/frontend/src/routes/_layout/dashboards/$name.tsx new file mode 100644 index 0000000..1b3cdcb --- /dev/null +++ b/frontend/src/routes/_layout/dashboards/$name.tsx @@ -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): 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(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 ( +
+
+

{dashboard.title || dashboard.name}

+
+ {edit ? ( + + ) : null} + +
+
+ + {pagesOf(dashboard).length > 1 ? ( + + + {pagesOf(dashboard).map((page) => ( + + {page.title || page.id} + + ))} + + + ) : null} + + {edit ? ( + + ) : ( + + )} +
+ ) +} diff --git a/frontend/src/routes/_layout/dashboards/index.tsx b/frontend/src/routes/_layout/dashboards/index.tsx new file mode 100644 index 0000000..4a1c5d0 --- /dev/null +++ b/frontend/src/routes/_layout/dashboards/index.tsx @@ -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 ( +
+
+

Dashboards

+

+ What a wall panel shows, built from the messages your flows carry. +

+
+ +
{ + event.preventDefault() + if (slug) create.mutate(slug) + }} + > + setName(event.target.value)} + /> + +
+ + {dashboards.length === 0 ? ( +

+ No dashboards yet. Name one above to start. +

+ ) : ( +
+ {dashboards.map((dashboard) => ( + + + + {dashboard.title || dashboard.name} + + + {dashboard.widget_count} widget + {dashboard.widget_count === 1 ? "" : "s"} ·{" "} + {dashboard.page_count} page + {dashboard.page_count === 1 ? "" : "s"} + + + ))} +
+ )} +
+ ) +}