From fef0545ae43432c9fcb8f27527f0c926dbabf643 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 16 Aug 2026 08:20:02 +0200 Subject: [PATCH] Let a function node's own settings be edited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A python node's params reach process() as whatever its author put there, but there was no way to put anything there: the settings form is built from a type's declared schema, and a function node declares none. Node types now say whether they take settings beyond their schema, and the panel offers a key/value editor for the ones that do — named, typed as text, number, on/off or JSON, and laid out like the port list beside it. Rows are keyed by position rather than by name, so renaming a setting does not remount the row and lose what was being typed into it. Verified in the running app in both themes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY --- NOTEPAD.md | 4 + backend/app/flow/controller.py | 3 + backend/app/flow/schemas.py | 4 + frontend/scripts/verify-params.mjs | 72 +++++++++ frontend/src/client/schemas.gen.ts | 5 + frontend/src/client/types.gen.ts | 1 + frontend/src/components/Flow/NodePanel.tsx | 176 +++++++++++++++++++++ 7 files changed, 265 insertions(+) create mode 100644 frontend/scripts/verify-params.mjs diff --git a/NOTEPAD.md b/NOTEPAD.md index 6160090..5321038 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -74,6 +74,10 @@ Deferring because out of scope is fine, but don't mention deferring than. threshold. React Flow and Monaco are already lazy; a manualChunks split measured no better, so this needs route-level work on the shell rather than chunking config. +- CHORE/INFRA: `bun run --filter frontend build` fails on this workspace with + `crypto.hash is not a function` — Vite 7 wants Node 20.12+ and the host has 18. The + Docker image builds fine, so it only bites local bundling; `bunx tsc` still type-checks. + ## Blocked - FEAT/UI: a "Bug" icon on the node error bubble opening the logs panel at that node's diff --git a/backend/app/flow/controller.py b/backend/app/flow/controller.py index 5ed05bf..192f100 100644 --- a/backend/app/flow/controller.py +++ b/backend/app/flow/controller.py @@ -101,6 +101,7 @@ class NodeType: # Constructor signatures differ per node type. cls: Any has_source: bool = False + free_params: bool = False params_schema: dict[str, Any] = field(default_factory=dict) #: Which installed package supplied this type, for the ones that are not #: built in. @@ -118,6 +119,7 @@ NODE_TYPES: dict[str, NodeType] = { description="Your own Python code, run on every incoming message.", cls=Node, has_source=True, + free_params=True, ), "mqtt": NodeType( title="MQTT", @@ -214,6 +216,7 @@ def node_type_info() -> list[NodeTypeInfo]: description=spec.description, params_schema=spec.params_schema, has_source=spec.has_source, + free_params=spec.free_params, plugin=spec.plugin, ) for key, spec in NODE_TYPES.items() diff --git a/backend/app/flow/schemas.py b/backend/app/flow/schemas.py index fae3dd3..60faf1c 100644 --- a/backend/app/flow/schemas.py +++ b/backend/app/flow/schemas.py @@ -155,5 +155,9 @@ class NodeTypeInfo(BaseModel): description: str params_schema: dict[str, Any] = Field(default_factory=dict) has_source: bool = False + #: Whether this type takes settings beyond the ones its schema declares. + #: A function node's params are its author's to name, and reach `process` + #: as whatever they put there. + free_params: bool = False #: The package a connector came from; empty for the built-in types. plugin: str | None = None diff --git a/frontend/scripts/verify-params.mjs b/frontend/scripts/verify-params.mjs new file mode 100644 index 0000000..3bc10d1 --- /dev/null +++ b/frontend/scripts/verify-params.mjs @@ -0,0 +1,72 @@ +/** Feature check: the free-form settings editor on a function node. */ +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 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}/flows`, { waitUntil: "networkidle" }) + const seed = page.getByTestId("create-first-flow") + if (await seed.count()) { + await seed.click() + await page.waitForURL(/\/flows\/.+/, { timeout: 15000 }) + } + if (!(await page.locator(".react-flow__node").count())) { + await page.getByTestId("add-node").click() + await page + .getByRole("option", { name: /function/i }) + .first() + .click() + await page.waitForSelector(".react-flow__node") + } + await page.locator(".react-flow__node").first().click() + await page.waitForSelector("[data-testid=node-panel]", { timeout: 15000 }) + + // Add two settings and give them values. + await page.getByTestId("add-param").click() + await page.waitForTimeout(300) + const names = page.getByLabel("Setting name") + const values = page.getByLabel("Setting value") + await names.first().fill("threshold") + await names.first().blur() + await page.waitForTimeout(200) + await page.getByLabel("Type").first().click() + await page.getByRole("option", { name: "number" }).click() + await page.waitForTimeout(200) + await values.first().fill("21.5") + await page.waitForTimeout(200) + + await page.getByTestId("add-param").click() + await page.waitForTimeout(300) + await names.nth(1).fill("label") + await names.nth(1).blur() + await page.waitForTimeout(200) + await values.nth(1).fill("living room") + await page.waitForTimeout(1200) + + await page.screenshot({ path: `${dir}/app-node-params.png` }) + console.log(` wrote ${dir}/app-node-params.png`) + await context.close() +} +await browser.close() diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 58d0dcc..335cd70 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -831,6 +831,11 @@ export const NodeTypeInfoSchema = { title: 'Has Source', default: false }, + free_params: { + type: 'boolean', + title: 'Free Params', + default: false + }, plugin: { anyOf: [ { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index ed582d7..2e4dfc4 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -264,6 +264,7 @@ export type NodeTypeInfo = { [key: string]: unknown; }; has_source?: boolean; + free_params?: boolean; plugin?: (string | null); }; diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index 3707469..75acdae 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -48,6 +48,9 @@ const NO_SECRET = "__none__" const SECTION = PANEL_SECTION +/** Settings the engine reads itself, so they are not the author's to name. */ +const RESERVED_PARAMS = new Set(["synchronous"]) + /** * A message name, typed freely or picked from the names already in play. * @@ -253,6 +256,172 @@ function PortList({ ) } +/** The value types a free-form setting can hold, and how to read one back. */ +const FREE_TYPES = ["text", "number", "on/off", "json"] as const +type FreeType = (typeof FREE_TYPES)[number] + +function freeTypeOf(value: unknown): FreeType { + if (typeof value === "boolean") return "on/off" + if (typeof value === "number") return "number" + if (value !== null && typeof value === "object") return "json" + return "text" +} + +function castTo(type: FreeType, raw: string): unknown { + if (type === "number") return Number(raw) || 0 + if (type === "on/off") return raw === "true" + if (type === "json") { + try { + return JSON.parse(raw) + } catch { + // Half-typed JSON is normal while editing; keep the text until it parses. + return raw + } + } + return raw +} + +function asText(value: unknown): string { + if (value === null || value === undefined) return "" + if (typeof value === "object") return JSON.stringify(value) + return String(value) +} + +/** + * Settings a node type does not declare. + * + * A function node's parameters are its author's to name — they arrive in + * `process` as whatever was put here — so there is no schema to render and the + * keys are typed in alongside the values. + */ +function FreeParamsForm({ + params, + reserved, + onChange, +}: { + params: Record + reserved: Set + onChange: (next: Record) => void +}) { + const [freshKey, setFreshKey] = useState(null) + const entries = Object.entries(params).filter(([key]) => !reserved.has(key)) + + const rename = (from: string, to: string) => { + if (to === from) return + // Rebuilt rather than patched, so the settings keep the order they were + // typed in instead of jumping around as one is renamed. + const next: Record = {} + for (const [key, value] of Object.entries(params)) { + next[key === from ? to : key] = value + } + onChange(next) + } + + return ( +
+
+ Settings + +
+ + {entries.length === 0 ? ( +

+ Values your code reads from params. +

+ ) : null} + + {entries.map(([key, value], index) => { + const type = freeTypeOf(value) + return ( + // Keyed by position, not by name: renaming a setting must not + // remount its row and take the half-typed value with it. +
+ rename(key, event.target.value.trim() || key)} + /> + + {type === "on/off" ? ( + + onChange({ ...params, [key]: checked }) + } + /> + ) : ( + + onChange({ + ...params, + [key]: castTo(type, event.target.value), + }) + } + /> + )} + +
+ ) + })} +
+ ) +} + /** A small form built from the node type's declared parameters. */ function ParamsForm({ schema, @@ -557,6 +726,13 @@ function PanelBody({ params={node.params ?? {}} onChange={(params) => onChange({ ...node, params })} /> + {nodeType?.free_params ? ( + onChange({ ...node, params })} + /> + ) : null} {hasSource ? ( ) : null}