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:
@@ -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,
|
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.
|
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
|
## Blocked
|
||||||
|
|
||||||
- FEAT/UI: a "Bug" icon on the node error bubble opening the logs panel at that node's
|
- FEAT/UI: a "Bug" icon on the node error bubble opening the logs panel at that node's
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ class NodeType:
|
|||||||
# Constructor signatures differ per node type.
|
# Constructor signatures differ per node type.
|
||||||
cls: Any
|
cls: Any
|
||||||
has_source: bool = False
|
has_source: bool = False
|
||||||
|
free_params: bool = False
|
||||||
params_schema: dict[str, Any] = field(default_factory=dict)
|
params_schema: dict[str, Any] = field(default_factory=dict)
|
||||||
#: Which installed package supplied this type, for the ones that are not
|
#: Which installed package supplied this type, for the ones that are not
|
||||||
#: built in.
|
#: built in.
|
||||||
@@ -118,6 +119,7 @@ NODE_TYPES: dict[str, NodeType] = {
|
|||||||
description="Your own Python code, run on every incoming message.",
|
description="Your own Python code, run on every incoming message.",
|
||||||
cls=Node,
|
cls=Node,
|
||||||
has_source=True,
|
has_source=True,
|
||||||
|
free_params=True,
|
||||||
),
|
),
|
||||||
"mqtt": NodeType(
|
"mqtt": NodeType(
|
||||||
title="MQTT",
|
title="MQTT",
|
||||||
@@ -214,6 +216,7 @@ def node_type_info() -> list[NodeTypeInfo]:
|
|||||||
description=spec.description,
|
description=spec.description,
|
||||||
params_schema=spec.params_schema,
|
params_schema=spec.params_schema,
|
||||||
has_source=spec.has_source,
|
has_source=spec.has_source,
|
||||||
|
free_params=spec.free_params,
|
||||||
plugin=spec.plugin,
|
plugin=spec.plugin,
|
||||||
)
|
)
|
||||||
for key, spec in NODE_TYPES.items()
|
for key, spec in NODE_TYPES.items()
|
||||||
|
|||||||
@@ -155,5 +155,9 @@ class NodeTypeInfo(BaseModel):
|
|||||||
description: str
|
description: str
|
||||||
params_schema: dict[str, Any] = Field(default_factory=dict)
|
params_schema: dict[str, Any] = Field(default_factory=dict)
|
||||||
has_source: bool = False
|
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.
|
#: The package a connector came from; empty for the built-in types.
|
||||||
plugin: str | None = None
|
plugin: str | None = None
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -831,6 +831,11 @@ export const NodeTypeInfoSchema = {
|
|||||||
title: 'Has Source',
|
title: 'Has Source',
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
|
free_params: {
|
||||||
|
type: 'boolean',
|
||||||
|
title: 'Free Params',
|
||||||
|
default: false
|
||||||
|
},
|
||||||
plugin: {
|
plugin: {
|
||||||
anyOf: [
|
anyOf: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ export type NodeTypeInfo = {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
has_source?: boolean;
|
has_source?: boolean;
|
||||||
|
free_params?: boolean;
|
||||||
plugin?: (string | null);
|
plugin?: (string | null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ const NO_SECRET = "__none__"
|
|||||||
|
|
||||||
const SECTION = PANEL_SECTION
|
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.
|
* 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. */
|
/** A small form built from the node type's declared parameters. */
|
||||||
function ParamsForm({
|
function ParamsForm({
|
||||||
schema,
|
schema,
|
||||||
@@ -557,6 +726,13 @@ function PanelBody({
|
|||||||
params={node.params ?? {}}
|
params={node.params ?? {}}
|
||||||
onChange={(params) => onChange({ ...node, params })}
|
onChange={(params) => onChange({ ...node, params })}
|
||||||
/>
|
/>
|
||||||
|
{nodeType?.free_params ? (
|
||||||
|
<FreeParamsForm
|
||||||
|
params={node.params ?? {}}
|
||||||
|
reserved={RESERVED_PARAMS}
|
||||||
|
onChange={(params) => onChange({ ...node, params })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{hasSource ? (
|
{hasSource ? (
|
||||||
<SharingSection flow={flow} node={node} onShared={onShared} />
|
<SharingSection flow={flow} node={node} onShared={onShared} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user