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:
@@ -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