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:
@@ -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',
|
||||
default: false
|
||||
},
|
||||
free_params: {
|
||||
type: 'boolean',
|
||||
title: 'Free Params',
|
||||
default: false
|
||||
},
|
||||
plugin: {
|
||||
anyOf: [
|
||||
{
|
||||
|
||||
@@ -264,6 +264,7 @@ export type NodeTypeInfo = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
has_source?: boolean;
|
||||
free_params?: boolean;
|
||||
plugin?: (string | null);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user