From f1e13cdcd47c9c6bd391ae4a270ff4bf0b50b0fd Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 20 Aug 2026 18:10:16 +0200 Subject: [PATCH] Flow inputs and outputs are visible and editable in the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flow's inputs are the messages it takes from outside — a dashboard control, a run, the API — and its outputs are what a batch run reports. Both existed in the document and in the engine, and neither had any UI: the values looked hard-coded on the canvas and the Run button always used the declared defaults. The canvas now draws each as a labelled endpoint, the way it already draws a dashboard tile or another flow, skipping an input something else already accounts for. The flow panel edits them — mode, name, type, starting value, and for a live flow the value it currently holds with a way to put a new one in. Pressing Run on a batch flow asks for its parameters first. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NUb8YpL2s3gmN9WTACTt4q --- NOTEPAD.md | 4 +- frontend/src/components/Flow/EndpointNode.tsx | 4 +- frontend/src/components/Flow/FlowBoundary.tsx | 341 ++++++++++++++++++ frontend/src/components/Flow/FlowEditor.tsx | 65 +++- frontend/src/components/Flow/FlowPanel.tsx | 8 + frontend/src/components/Flow/NodePanel.tsx | 2 +- frontend/src/components/Flow/RunDialog.tsx | 139 +++++++ frontend/src/components/Flow/deriveEdges.ts | 15 + frontend/src/components/Flow/endpoints.ts | 66 +++- frontend/tests/endpoints.spec.ts | 22 +- 10 files changed, 644 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/Flow/FlowBoundary.tsx create mode 100644 frontend/src/components/Flow/RunDialog.tsx diff --git a/NOTEPAD.md b/NOTEPAD.md index 495d5e3..3bfef05 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -16,6 +16,8 @@ Deferring because out of scope is fine, but don't mention deferring than. - FEAT/UI add animation to widgets; i.e. status of bars, gauges etc. should fade from one state to another. Multi-buttons (like "Mode" in the "Home" dashboard of the demo) should transition from one state to another; use inspiration for animations based on the google material guidelines - BUG when clicking "edit" in the "Home" dashboard of the demo on hub.fluksio.com, most of the panels disappear (only a handfull is left for actual edit) - CHORE/UI: the edge popover shows the same value twice — `MessageSparkline` falls through to a collapsed `ValuePreview` for a non-numeric value, and `EdgeInspector` then renders its own `ValuePreview defaultOpen` below it. Cosmetic; one of the two is redundant. +- FEAT/UI: a settings-and-inputs overview page, so what every node of an installation is configured with can be read and searched in one place rather than one panel at a time. +- FEAT/UI: an input endpoint opens the flow panel, which is right for editing but not for reading one value. A panel of its own — the declaration, the current value, its history — is what clicking a label wants to give. - FEAT/UI we should introduce a sync between the header of the python function and the node configuration; i.e. adding an input/output or static paramter would change the header of the python function and changing the python function header and vice versa - BUG/UI on flows like "House history" where the widget sets the range for the "draw the window" node to generate some data, the edges overlap the nodes. We should adjust the flow visualization to account for these cyclic behaviors - BUG/UI when enlarging the code editor of a node, the code editor should enlarge to the left (node settings remain on the right) so that the code editor fills the center of the screen with the node properties available next to it @@ -210,7 +212,7 @@ Open on purpose. Each names what should bring it back. - FEAT/RUNS: stage caching. `run_node.cache_key` is written on every run and the artifact store is content-addressed, so the pieces are in place; what is missing is computing the key from the node's source digest plus its input values and skipping a node whose key already has an `ok` row with its artifacts still present. The two research repos want this more than they want resume — neither persists checkpoints, and both re-run unchanged preprocessing every time. - FEAT/RUNS: per-label requirements overlays (`requirements-gpu.txt`) synced into a remote worker's venv, with drift surfaced against the engine's manifest. Today a worker's environment is whatever `--python` points at, which is fine for one hand-managed GPU box and not for several. `venv_digest` already arrives at attach and is shown on `/workers`, so the reporting half exists. - FEAT/UI: a dashboard shows a run's curve only while it is running. Emissions reach the socket live, but a run's values live in its own state namespace, so reloading the panel afterwards leaves the chart empty — the durable series is on the run (`/runs/{id}/metrics`) and nothing binds a widget to it. A chart variant that reads a run's series, or the existing querying chart pointed at `/runs/series/compare`, is what would close it. This is also what a demo needs to show a finished experiment rather than only a live one. -- FEAT/UI: nothing submits a run with parameters from the UI. Pressing Run on a batch flow submits one with the declared defaults, which is enough to try it; choosing parameters, or launching a sweep, is API-only. A form generated from the flow's `inputs` is the obvious shape. +- FEAT/UI: launching a sweep is API-only. Pressing Run on a batch flow asks for its parameters, but the many-runs-at-once shape has no UI; a run detail screen is what it wants to land next to. - FEAT/RUNS: a run detail screen. The API answers everything — params, per-node status with logs and tracebacks, artifacts, metrics, and `/runs/series/compare` in the chart widget's own `series` shape — but nothing in the dashboard reads it yet, so a run is inspected over HTTP. Comparing curves is a widget binding once someone builds the page around it. - FEAT/RUNS: a thin client CLI (`fluksio run/runs/sweep/worker`) over the same API. The engine being resident is what makes runs cheap; a CLI is ergonomics on top, and `curl` covers it until someone is running sweeps daily. - FEAT/RUNS: the step on a run's series is the count of emissions on that message, so a node yielding every tenth training step records steps 0, 1, 2 rather than 0, 10, 20 — a faithful x-axis of its own emissions, not of the loop inside it. If a real step number ever matters, a `record`-typed streaming port carrying its own `step` is the shape to read it from; the column is already there. diff --git a/frontend/src/components/Flow/EndpointNode.tsx b/frontend/src/components/Flow/EndpointNode.tsx index 81d945e..fa4461c 100644 --- a/frontend/src/components/Flow/EndpointNode.tsx +++ b/frontend/src/components/Flow/EndpointNode.tsx @@ -1,5 +1,5 @@ import { Handle, type NodeProps, Position } from "@xyflow/react" -import { LayoutDashboard, Workflow } from "lucide-react" +import { LayoutDashboard, LogIn, LogOut, Workflow } from "lucide-react" import { memo } from "react" import { useIsMobile } from "@/hooks/useMobile" @@ -9,6 +9,8 @@ import type { EndpointNodeData } from "./endpoints" const KIND_ICONS = { dashboard: LayoutDashboard, flow: Workflow, + input: LogIn, + output: LogOut, } as const /** diff --git a/frontend/src/components/Flow/FlowBoundary.tsx b/frontend/src/components/Flow/FlowBoundary.tsx new file mode 100644 index 0000000..08fb1ea --- /dev/null +++ b/frontend/src/components/Flow/FlowBoundary.tsx @@ -0,0 +1,341 @@ +import { useMutation } from "@tanstack/react-query" +import { ArrowUpFromLine, Plus, X } from "lucide-react" + +import type { DType, FlowDef_Input, FlowInput_Input } from "@/client" +import { MessagesService } from "@/client" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { qualify } from "./deriveEdges" +import { useLiveValue } from "./liveStore" +import { DTYPES } from "./NodePanel" +import { PANEL_SECTION } from "./SidePanel" +import { ValuePreview } from "./ValuePreview" + +/** + * A typed literal, read back from what was typed. + * + * Half-finished input is normal while editing — "-", "1.", a JSON object with + * one brace — so anything that does not parse yet is kept as text rather than + * replaced with a zero the person did not type. + */ +export function parseByDtype(dtype: DType | undefined, raw: string): unknown { + if (raw === "") return null + if (dtype === "int" || dtype === "float") { + const parsed = Number(raw) + return Number.isNaN(parsed) ? raw : parsed + } + if (dtype === "bool") return raw === "true" + if (dtype === "str") return raw + try { + return JSON.parse(raw) + } catch { + return raw + } +} + +/** The same value as something a text field can hold. */ +export function asText(value: unknown): string { + if (value === null || value === undefined) return "" + if (typeof value === "object") return JSON.stringify(value) + return String(value) +} + +/** Putting a declared value into the running graph, credited to the input. */ +function usePublishInput() { + return useMutation({ + mutationFn: ({ message, value }: { message: string; value: unknown }) => + MessagesService.publishMessage({ + name: message, + requestBody: { + value, + source_kind: "flow", + // Matches the endpoint id the canvas builds, so that label pulses. + source_id: `input:${message}`, + source_label: message, + source_detail: "input", + }, + }), + }) +} + +function InputRow({ + flow, + declared, + live, + onChange, + onRemove, +}: { + flow: string + declared: FlowInput_Input + /** Whether the engine is running this flow, so a value can be put into it. */ + live: boolean + onChange: (next: FlowInput_Input) => void + onRemove: () => void +}) { + const spec = declared.spec ?? {} + const name = spec.name ?? "" + const message = qualify(flow, name) + const current = useLiveValue(message) + const publish = usePublishInput() + + return ( +
+
+ + onChange({ + ...declared, + spec: { ...spec, name: event.target.value.trim() }, + }) + } + /> + + {spec.dtype === "bool" ? ( + + ) : ( + + onChange({ + ...declared, + initial: parseByDtype(spec.dtype, event.target.value), + }) + } + /> + )} + +
+ + {live && name ? ( +
+ now + {current === undefined ? ( + + ) : ( + + + + )} + + + + + Put this value into the flow now + +
+ ) : null} +
+ ) +} + +/** + * What the flow takes from outside, and what a run of it reports. + * + * An input is a message no node here computes — a control writes it, a run + * passes it in, an agent publishes it — so the flow declares it and says what + * it starts from. Without that the node reading it waits for something nothing + * provides, which is what the canvas reports. + */ +export function BoundarySections({ + flow, + definition, + running, + onChange, +}: { + flow: string + definition: FlowDef_Input + /** Whether the engine is running this flow, so it holds values worth showing. */ + running: boolean + onChange: (next: FlowDef_Input) => void +}) { + const inputs = definition.inputs ?? [] + const batch = definition.mode === "batch" + const outputs = definition.outputs ?? [] + // A run reads its parameters into a namespace of its own, so what the engine + // holds for a batch flow is not what any run of it saw. + const live = running && !batch + + // Everything the flow computes, which is what a result can be made of. + const provided = [ + ...new Set( + (definition.nodes ?? []).flatMap((node) => + (node.provides ?? []).map((spec) => spec.name ?? "").filter(Boolean), + ), + ), + ].sort() + + const setInputs = (next: FlowInput_Input[]) => + onChange({ ...definition, inputs: next }) + + return ( + <> +
+ Mode +
+

+ {batch + ? "Runs when a run asks it to, from its inputs to its outputs. Nothing here is subscribed or scheduled." + : "Runs continuously: subscriptions, schedules and webhooks are live."} +

+ +
+
+ +
+
+ Inputs + +
+ + {inputs.length === 0 ? ( +

+ Messages that arrive from outside — a dashboard control, a run, the + API — and the value the flow starts from. +

+ ) : null} + + {inputs.map((declared, index) => ( + // Keyed by position: renaming an input must not remount its row. + + setInputs(inputs.map((one, at) => (at === index ? next : one))) + } + onRemove={() => + setInputs(inputs.filter((_one, at) => at !== index)) + } + /> + ))} +
+ + {batch ? ( +
+ Result +

+ {provided.length === 0 + ? "Nothing is produced here yet, so a run has nothing to report." + : "What a run reports when it finishes. Everything else it computed goes with it."} +

+ {provided.map((name) => ( + + ))} +
+ ) : null} + + ) +} diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index de01d88..f2c8e2d 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -47,7 +47,13 @@ import { inCodeEditor, useShortcuts } from "@/lib/shortcuts" import { cn } from "@/lib/utils" import { CanvasTitle } from "./CanvasTitle" import { CommandPalette } from "./CommandPalette" -import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges" +import { + bindingsKey, + boundaryKey, + deriveEdges, + portOf, + qualify, +} from "./deriveEdges" import { EdgeInspector, type InspectedEdge } from "./EdgeInspector" import { EndpointNode } from "./EndpointNode" import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints" @@ -57,6 +63,7 @@ import { FlowPanel } from "./FlowPanel" import { LiveEdge } from "./LiveEdge" import { type Direction, layoutGraph } from "./layout" import { NodePanel } from "./NodePanel" +import { RunDialog } from "./RunDialog" import "./flow.css" import { liveStore, useFlowPaused } from "./liveStore" import { @@ -228,6 +235,8 @@ function FlowEditorInner({ const [rebind, setRebind] = useState(null) const [renamed, setRenamed] = useState(null) const [flowPanelOpen, setFlowPanelOpen] = useState(false) + // A batch run is asked for its parameters before it is submitted. + const [runOpen, setRunOpen] = useState(false) // Throwing an edit away is offered from the dock, so its confirmation lives // here rather than inside the settings panel. const [discardOpen, setDiscardOpen] = useState(false) @@ -368,8 +377,9 @@ function FlowEditorInner({ ) // A cheap fingerprint of the wiring, and the only thing the layout depends - // on: what the graph looks like follows from what is wired to what. - const key = bindingsKey(definitions) + // on: what the graph looks like follows from what is wired to what. The + // flow's own boundary is drawn too, so a declared input counts as wiring. + const key = `${bindingsKey(definitions)}|${boundaryKey(flowDoc)}` // Offer the names already in play: everything published is worth reading, // and an input nobody provides yet is worth publishing. @@ -395,7 +405,11 @@ function FlowEditorInner({ const openEndpoint = useCallback( (id: string) => { const [kind, rest] = id.split(":", 2) - if (kind === "dashboard") { + if (kind === "input" || kind === "output") { + // This flow's own boundary, declared in its settings. + setSelectedId(null) + setFlowPanelOpen(true) + } else if (kind === "dashboard") { navigate({ to: "/dashboards/$name", params: { name: (rest ?? "").split(":")[0] }, @@ -448,7 +462,12 @@ function FlowEditorInner({ // canvasNodes, so an autosave, an undo or a delete cannot reach them. // biome-ignore lint/correctness/useExhaustiveDependencies: the key covers the wiring, which is all these depend on. const external = useMemo(() => { - const built = deriveEndpoints(detail.endpoints ?? [], definitions, flowName) + const built = deriveEndpoints( + detail.endpoints ?? [], + definitions, + flowName, + flowDoc, + ) return { ...built, nodes: built.nodes.map((node) => { @@ -552,8 +571,8 @@ function FlowEditorInner({ }, [direction, definitions.length, external.nodes.length, fitView]) const runMutation = useMutation({ - mutationFn: () => - FlowsService.runFlow({ name: flowName, requestBody: { inputs: {} } }), + mutationFn: (inputs: Record = {}) => + FlowsService.runFlow({ name: flowName, requestBody: { inputs } }), onSuccess: (state) => { liveStore.setValues( Object.fromEntries( @@ -568,6 +587,19 @@ function FlowEditorInner({ showErrorToast("The flow could not run. Check the node errors."), }) + /** + * Pressing Run. + * + * A batch flow is submitted as a run, and a run is identified by its + * parameters — so it asks for them rather than quietly using the defaults. + */ + const startRun = useCallback(async () => { + // Running executes what is stored, so the queued edit goes first. + await flush() + if (latest.current.mode === "batch") setRunOpen(true) + else runMutation.mutate({}) + }, [flush, runMutation]) + const enableMutation = useMutation({ mutationFn: (next: boolean) => next @@ -1044,11 +1076,7 @@ function FlowEditorInner({ onClearNode: () => setLogsNode(null), }} onAddNode={() => setPaletteOpen(true)} - onRun={async () => { - // Running executes what is stored, so the queued edit goes first. - await flush() - runMutation.mutate() - }} + onRun={startRun} onTogglePause={() => pauseMutation.mutate(!paused)} onStep={() => stepMutation.mutate()} stepping={stepMutation.isPending} @@ -1125,7 +1153,18 @@ function FlowEditorInner({ flows={flows.data} onAddNode={addNode} onAddSharedNode={(libName) => addNode("python", libName)} - onRun={() => runMutation.mutate()} + onRun={startRun} + /> + + { + setRunOpen(false) + runMutation.mutate(params) + }} /> + +
Contents

diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index d818778..c402eec 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -50,7 +50,7 @@ import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel" const NodeEditor = lazy(() => import("./NodeEditor")) -const DTYPES: DType[] = [ +export const DTYPES: DType[] = [ "float", "int", "str", diff --git a/frontend/src/components/Flow/RunDialog.tsx b/frontend/src/components/Flow/RunDialog.tsx new file mode 100644 index 0000000..57d4f82 --- /dev/null +++ b/frontend/src/components/Flow/RunDialog.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from "react" + +import type { FlowDef_Input } from "@/client" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { asText, parseByDtype } from "./FlowBoundary" + +/** + * The parameters of one run, taken from the flow's inputs. + * + * A batch flow's inputs *are* its parameters — a run supplies values for them + * and keeps whatever it does not name — so this is a form over the same + * declarations the flow panel edits, prefilled with what they start from. + */ +export function RunDialog({ + open, + definition, + pending, + onOpenChange, + onRun, +}: { + open: boolean + definition: FlowDef_Input + pending: boolean + onOpenChange: (open: boolean) => void + onRun: (params: Record) => void +}) { + const inputs = (definition.inputs ?? []).filter((one) => one.spec?.name) + const [values, setValues] = useState>({}) + + // Opening is what fills the form: an edit to the flow between two runs + // should show up, and the last run's values should not linger. The inputs + // are read then rather than watched, so typing over one is not undone. + // biome-ignore lint/correctness/useExhaustiveDependencies: opening is the dependency. + useEffect(() => { + if (!open) return + setValues( + Object.fromEntries( + inputs.map((one) => [one.spec?.name ?? "", one.initial ?? null]), + ), + ) + }, [open]) + + return ( +

+ + + Run {definition.title || definition.name} + + {inputs.length === 0 + ? "This flow declares no parameters, so there is nothing to choose." + : "The run starts from these. Everything it computes is dropped afterwards except its result."} + + + +
+ {inputs.map((declared) => { + const name = declared.spec?.name ?? "" + const dtype = declared.spec?.dtype + return ( +
+ + {dtype === "bool" ? ( + + ) : ( + + setValues({ + ...values, + [name]: parseByDtype(dtype, event.target.value), + }) + } + /> + )} +
+ ) + })} +
+ + + + + +
+
+ ) +} diff --git a/frontend/src/components/Flow/deriveEdges.ts b/frontend/src/components/Flow/deriveEdges.ts index 9ead9be..ede5de9 100644 --- a/frontend/src/components/Flow/deriveEdges.ts +++ b/frontend/src/components/Flow/deriveEdges.ts @@ -85,6 +85,21 @@ export function deriveEdges(nodes: NodeDef_Input[], flow: string): Edge[] { return edges } +/** + * A cheap fingerprint of the flow's boundary — what it takes from outside and, + * for a batch flow, what it reports — since those are drawn like wiring too. + */ +export function boundaryKey(doc: { + mode?: string | null + inputs?: { spec?: MessageSpec | null }[] | null + outputs?: string[] | null +}): string { + const inputs = (doc.inputs ?? []) + .map((one) => `${one.spec?.name ?? ""}:${one.spec?.dtype ?? ""}`) + .join(",") + return `${doc.mode ?? "live"}|${inputs}|${(doc.outputs ?? []).join(",")}` +} + /** * A cheap fingerprint of everything edges depend on, so dragging a node (which * replaces the array every frame) does not re-derive them. diff --git a/frontend/src/components/Flow/endpoints.ts b/frontend/src/components/Flow/endpoints.ts index a1456f7..7a96551 100644 --- a/frontend/src/components/Flow/endpoints.ts +++ b/frontend/src/components/Flow/endpoints.ts @@ -1,8 +1,8 @@ import type { Edge, Node as FlowCanvasNode } from "@xyflow/react" -import type { Endpoint, NodeDef_Input } from "@/client" +import type { Endpoint, FlowDef_Input, NodeDef_Input } from "@/client" import type { FlowEdgeData } from "./deriveEdges" -import { portOf, qualify } from "./deriveEdges" +import { displayName, portOf, qualify } from "./deriveEdges" /** * Canvas elements for things wired into a flow that are not nodes in it. @@ -20,6 +20,9 @@ import { portOf, qualify } from "./deriveEdges" /** Marks a canvas node as one of these, for anything that has to skip them. */ export const ENDPOINT_TYPE = "endpoint" +/** The id prefixes an endpoint may carry, none of which is a node id. */ +const PREFIXES = ["dashboard:", "flow:", "input:", "output:"] + export type EndpointNodeData = { label: string kind: string @@ -31,7 +34,57 @@ export type EndpointNodeData = { } export function isEndpointNode(node: { id: string }): boolean { - return node.id.startsWith("dashboard:") || node.id.startsWith("flow:") + return PREFIXES.some((prefix) => node.id.startsWith(prefix)) +} + +/** + * The flow's own boundary, as endpoints. + * + * An input is a message written from outside — a control, a run, the API — and + * an output is what a batch run reports. They come from the working document + * rather than from the server, so declaring one shows up on the canvas before + * it has been published. + * + * An input a dashboard or another flow already accounts for is left out: the + * label exists so a value has a visible origin, and it already has one. + */ +function boundaryOf( + doc: FlowDef_Input, + flow: string, + accounted: Set, +): Endpoint[] { + const found: Endpoint[] = [] + + for (const declared of doc.inputs ?? []) { + const message = qualify(flow, declared.spec?.name ?? "") + if (!message || accounted.has(message)) continue + found.push({ + kind: "input", + id: `input:${message}`, + label: displayName(flow, message), + detail: declared.spec?.dtype ?? "float", + provides: [message], + requires: [], + }) + } + + // Only a batch flow has a result; a live one never finishes to have one. + if (doc.mode === "batch") { + for (const name of doc.outputs ?? []) { + const message = qualify(flow, name) + if (!message) continue + found.push({ + kind: "output", + id: `output:${message}`, + label: displayName(flow, message), + detail: "result", + provides: [], + requires: [message], + }) + } + } + + return found } /** @@ -46,8 +99,11 @@ export function deriveEndpoints( endpoints: Endpoint[], definitions: NodeDef_Input[], flow: string, + doc: FlowDef_Input, ): { nodes: FlowCanvasNode[]; edges: Edge[] } { - if (endpoints.length === 0) return { nodes: [], edges: [] } + const accounted = new Set(endpoints.flatMap((one) => one.provides ?? [])) + const all = [...boundaryOf(doc, flow, accounted), ...endpoints] + if (all.length === 0) return { nodes: [], edges: [] } // Which node consumes or produces each message, so a label can sit beside it. const consumers = new Map() @@ -77,7 +133,7 @@ export function deriveEndpoints( const nodes: FlowCanvasNode[] = [] const edges: Edge[] = [] - for (const endpoint of endpoints) { + for (const endpoint of all) { const produces = endpoint.provides ?? [] const reads = endpoint.requires ?? [] diff --git a/frontend/tests/endpoints.spec.ts b/frontend/tests/endpoints.spec.ts index 733b495..a7249be 100644 --- a/frontend/tests/endpoints.spec.ts +++ b/frontend/tests/endpoints.spec.ts @@ -29,12 +29,19 @@ test.beforeAll(async ({ browser }) => { { id: "sink", type: "python", - requires: [{ name: "level", port: "level", dtype: "float" }], + requires: [ + { name: "level", port: "level", dtype: "float" }, + { name: "ceiling", port: "ceiling", dtype: "float" }, + ], provides: [], }, ], inputs: [ { spec: { name: "level", port: "level", dtype: "float" }, initial: 0 }, + { + spec: { name: "ceiling", port: "ceiling", dtype: "float" }, + initial: 30, + }, ], version: 1, }, @@ -88,6 +95,19 @@ test("a dashboard control is drawn on the flow it feeds", async ({ page }) => { await expect(page.getByText("Lever")).toBeVisible() }) +test("an input nothing else writes is drawn, and the wired one is not", async ({ + page, +}) => { + await page.goto(`/flows/${flowName}`) + await page.waitForSelector(".react-flow__node") + + // The flow declares it and no node computes it, so it needs a visible + // origin. `level` already has one — the control below — and a second label + // saying the same thing would only be in the way. + await expect(page.getByText("ceiling")).toBeVisible() + await expect(page.getByText("level")).toHaveCount(0) +}) + test("the dashboard is never stored as a node of the flow", async ({ page, }) => {