Let a function node's own settings be edited

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
root
2026-08-16 08:20:17 +02:00
co-authored by Claude Fable 5
parent 55bdc49510
commit fef0545ae4
7 changed files with 265 additions and 0 deletions
+72
View File
@@ -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()
+5
View File
@@ -831,6 +831,11 @@ export const NodeTypeInfoSchema = {
title: 'Has Source',
default: false
},
free_params: {
type: 'boolean',
title: 'Free Params',
default: false
},
plugin: {
anyOf: [
{
+1
View File
@@ -264,6 +264,7 @@ export type NodeTypeInfo = {
[key: string]: unknown;
};
has_source?: boolean;
free_params?: boolean;
plugin?: (string | null);
};
+176
View File
@@ -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<string, unknown>
reserved: Set<string>
onChange: (next: Record<string, unknown>) => void
}) {
const [freshKey, setFreshKey] = useState<string | null>(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<string, unknown> = {}
for (const [key, value] of Object.entries(params)) {
next[key === from ? to : key] = value
}
onChange(next)
}
return (
<div className="grid gap-2">
<div className="flex items-center justify-between">
<span className={SECTION}>Settings</span>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
data-testid="add-param"
onClick={() => {
let name = "setting"
for (let i = 2; name in params; i++) name = `setting${i}`
setFreshKey(name)
onChange({ ...params, [name]: "" })
}}
>
Add
</Button>
</div>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">
Values your code reads from <code>params</code>.
</p>
) : 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.
<div key={`param-${index}`} className="flex items-center gap-1.5">
<Input
defaultValue={key}
placeholder="name"
aria-label="Setting name"
autoFocus={key === freshKey}
className="h-8 flex-1 text-sm"
onBlur={(event) => rename(key, event.target.value.trim() || key)}
/>
<Select
value={type}
onValueChange={(next) =>
onChange({
...params,
[key]: castTo(next as FreeType, asText(value)),
})
}
>
<SelectTrigger
className="!h-8 w-[86px] text-sm"
aria-label="Type"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREE_TYPES.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{type === "on/off" ? (
<Switch
checked={value === true}
aria-label="Value"
onCheckedChange={(checked) =>
onChange({ ...params, [key]: checked })
}
/>
) : (
<Input
value={asText(value)}
placeholder="value"
aria-label="Setting value"
className="h-8 flex-1 text-sm"
onChange={(event) =>
onChange({
...params,
[key]: castTo(type, event.target.value),
})
}
/>
)}
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove setting"
onClick={() => {
const next = { ...params }
delete next[key]
onChange(next)
}}
>
<X />
</Button>
</div>
)
})}
</div>
)
}
/** 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 ? (
<FreeParamsForm
params={node.params ?? {}}
reserved={RESERVED_PARAMS}
onChange={(params) => onChange({ ...node, params })}
/>
) : null}
{hasSource ? (
<SharingSection flow={flow} node={node} onShared={onShared} />
) : null}