Offer settings from other nodes, help with cron, follow the ports

Suggest a parameter's value from the nodes of the same type in every flow,
the way message names are already offered; secrets stay out of it. Explain
the cron fields, and derive the expression an interval asks for. Keep an
untouched function scaffold in step with the node's ports.
This commit is contained in:
2026-08-16 16:58:19 +02:00
parent 2746f579e1
commit 0be8bf5713
2 changed files with 305 additions and 78 deletions
+258 -78
View File
@@ -1,6 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Maximize2, Minimize2, X } from "lucide-react"
import { lazy, Suspense, useEffect, useRef, useState } from "react"
import {
type ComponentProps,
lazy,
Suspense,
useEffect,
useRef,
useState,
} from "react"
import {
type DType,
@@ -37,6 +44,7 @@ import {
libraryQueryOptions,
nodeSourceQueryOptions,
secretsQueryOptions,
useParamSuggestions,
} from "./queries"
import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
@@ -52,6 +60,93 @@ 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 text field that offers what is already in use elsewhere.
*
* Typing stays free — the list is a shortcut, never a constraint — but picking
* beats typing wherever two things only work together when they match.
*/
function SuggestInput({
value,
suggestions,
empty,
onChange,
onFocus,
onBlur,
...props
}: {
value: string
suggestions: string[]
/** What to say when nothing matches what is typed. */
empty: string
onChange: (next: string) => void
} & Omit<ComponentProps<typeof Input>, "value" | "onChange">) {
const [open, setOpen] = useState(false)
const matches = suggestions.filter(
(item) =>
item !== value && item.toLowerCase().includes(value.toLowerCase()),
)
return (
<Popover open={open && matches.length > 0} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<Input
value={value}
autoComplete="off"
{...props}
onFocus={(event) => {
setOpen(true)
onFocus?.(event)
}}
onBlur={(event) => {
setOpen(false)
onBlur?.(event)
}}
onChange={(event) => {
onChange(event.target.value)
setOpen(true)
}}
onKeyDown={(event) => {
if (event.key === "Escape") setOpen(false)
}}
/>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-[--radix-popover-trigger-width] p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<Command shouldFilter={false}>
<CommandList>
<CommandEmpty>{empty}</CommandEmpty>
<CommandGroup>
{matches.map((item) => (
<CommandItem
key={item}
value={item}
className="font-mono text-sm"
// Blur fires before click, so commit on mousedown.
onMouseDown={(event) => {
event.preventDefault()
onChange(item)
setOpen(false)
}}
onSelect={() => {
onChange(item)
setOpen(false)
}}
>
{item}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
/**
* A message name, typed freely or picked from the names already in play.
*
@@ -74,77 +169,29 @@ function MessageNameInput({
/** The name as it was before this edit, once the field is done with. */
onRenamed?: (previous: string, next: string) => void
}) {
const [open, setOpen] = useState(false)
// Every keystroke commits, so a rename is only a rename once the user is
// finished with the field.
const before = useRef(value)
const matches = suggestions.filter(
(name) =>
name !== value && name.toLowerCase().includes(value.toLowerCase()),
)
return (
<Popover open={open && matches.length > 0} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<Input
value={value}
placeholder={placeholder}
aria-label="Message name"
autoComplete="off"
// A port added by hand is meant to be named right away.
autoFocus={autoFocus}
className="h-8 flex-1 font-mono text-sm"
onFocus={() => {
before.current = value
setOpen(true)
}}
onBlur={() => {
setOpen(false)
if (before.current !== value) onRenamed?.(before.current, value)
before.current = value
}}
onChange={(event) => {
onChange(event.target.value)
setOpen(true)
}}
onKeyDown={(event) => {
if (event.key === "Escape") setOpen(false)
}}
/>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-[--radix-popover-trigger-width] p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<Command shouldFilter={false}>
<CommandList>
<CommandEmpty>No matching message.</CommandEmpty>
<CommandGroup>
{matches.map((name) => (
<CommandItem
key={name}
value={name}
className="font-mono text-sm"
// Blur fires before click, so commit on mousedown.
onMouseDown={(event) => {
event.preventDefault()
onChange(name)
setOpen(false)
}}
onSelect={() => {
onChange(name)
setOpen(false)
}}
>
{name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<SuggestInput
value={value}
suggestions={suggestions}
empty="No matching message."
placeholder={placeholder}
aria-label="Message name"
// A port added by hand is meant to be named right away.
autoFocus={autoFocus}
className="h-8 flex-1 font-mono text-sm"
onChange={onChange}
onFocus={() => {
before.current = value
}}
onBlur={() => {
if (before.current !== value) onRenamed?.(before.current, value)
before.current = value
}}
/>
)
}
@@ -423,18 +470,75 @@ function FreeParamsForm({
)
}
/**
* The cron expression that fires as often as `seconds` asks for, if there is
* one. Only divisors of an hour or a day line up — a step of seven minutes
* would jump from :56 back to :00, which is not every seven minutes.
*/
function cronFromInterval(seconds: unknown): string | null {
const value = Number(seconds)
if (!Number.isFinite(value) || value <= 0) return null
const minutes = value / 60
// Cron's finest grain is the minute.
if (!Number.isInteger(minutes)) return null
if (minutes === 1) return "* * * * *"
if (minutes < 60) return 60 % minutes === 0 ? `*/${minutes} * * * *` : null
const hours = minutes / 60
if (!Number.isInteger(hours)) return null
if (hours === 1) return "0 * * * *"
if (hours < 24) return 24 % hours === 0 ? `0 */${hours} * * *` : null
return hours === 24 ? "0 0 * * *" : null
}
/** What the five fields mean, and the schedule the interval beside them asks for. */
function CronHelp({
params,
onPick,
}: {
params: Record<string, unknown>
onPick: (expression: string) => void
}) {
const derived = cronFromInterval(params.interval)
return (
<>
<p className="text-xs text-muted-foreground">
Five fields —{" "}
<span className="font-mono">
minute hour day-of-month month weekday
</span>
. <span className="font-mono">*</span> is every,{" "}
<span className="font-mono">*/5</span> every fifth,{" "}
<span className="font-mono">1-5</span> a range.
</p>
{derived && params.cron !== derived ? (
<Button
variant="ghost"
size="sm"
className="h-7 justify-self-start text-xs text-muted-foreground"
data-testid="use-derived-cron"
onClick={() => onPick(derived)}
>
Every {String(params.interval)}s is
<span className="font-mono">{derived}</span>
</Button>
) : null}
</>
)
}
/** A small form built from the node type's declared parameters. */
function ParamsForm({
type,
schema,
params,
onChange,
}: {
type: string | undefined
schema: Record<string, unknown> | undefined
params: Record<string, unknown>
onChange: (next: Record<string, unknown>) => void
}) {
const { data: secretList } = useQuery(secretsQueryOptions())
const secrets = secretList?.data ?? []
const properties = (schema?.properties ?? {}) as Record<
string,
{
@@ -446,6 +550,12 @@ function ParamsForm({
}
>
const entries = Object.entries(properties)
const { data: secretList } = useQuery(secretsQueryOptions())
const secrets = secretList?.data ?? []
// A type with nothing to fill in has nothing to suggest, and the other flows
// are not worth reading for it.
const suggestions = useParamSuggestions(entries.length ? type : undefined)
if (entries.length === 0) return null
const set = (key: string, value: unknown) =>
@@ -519,23 +629,26 @@ function ParamsForm({
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
{label}
</Label>
<Input
<SuggestInput
id={`param-${key}`}
className="h-8 text-sm"
type={numeric ? "number" : "text"}
value={String(value)}
onChange={(event) =>
set(
key,
numeric ? Number(event.target.value) : event.target.value,
)
}
suggestions={suggestions[key] ?? []}
empty="Nothing like that in use yet."
onChange={(next) => set(key, numeric ? Number(next) : next)}
/>
{property.description ? (
<p className="text-xs text-muted-foreground">
{property.description}
</p>
) : null}
{key === "cron" ? (
<CronHelp
params={params}
onPick={(expression) => set(key, expression)}
/>
) : null}
</div>
)
})}
@@ -645,6 +758,57 @@ function SharingSection({
)
}
/** The value a fresh output returns, so the scaffold runs as written. */
const PLACEHOLDER: Record<DType, string> = {
float: "0.0",
int: "0",
bool: "False",
str: '""',
json: "{}",
}
const SCAFFOLD_DOC =
'"""A new node. Return a dict keyed by your output ports."""'
const quote = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
/**
* Exactly what `scaffoldFor` writes, for any set of ports — and the source a
* node starts life with, which is that with no ports at all.
*
* Nothing else matches, which is what makes it safe to overwrite: a returned
* value that is not one of the placeholders is somebody's code.
*/
const SCAFFOLD_SHAPE = (() => {
const value = Object.values(PLACEHOLDER).map(quote).join("|")
const entry = `"[^"]+": (?:${value})`
return new RegExp(
`^${quote(SCAFFOLD_DOC)}\\n\\n\\ndef process\\(\\w+(?:, \\w+)*\\):\\n return \\{(?:${entry}(?:, ${entry})*)?\\}\\n$`,
)
})()
/** The name the node function sees; the engine derives it the same way. */
const portName = (spec: MessageSpec) =>
spec.port || (spec.name ?? "").split(".").pop() || ""
/** A `process` that takes this node's inputs and returns its outputs. */
function scaffoldFor(node: NodeDef_Input): string {
const args = [
...new Set(
(node.requires ?? [])
.map(portName)
// Anything else cannot be a keyword argument, so it cannot be a port.
.filter((port) => /^[A-Za-z_]\w*$/.test(port) && port !== "params"),
),
]
const returns = (node.provides ?? [])
.filter((spec) => portName(spec))
.map((spec) => `"${portName(spec)}": ${PLACEHOLDER[spec.dtype ?? "float"]}`)
return `${SCAFFOLD_DOC}\n\n\ndef process(${[...args, "params"].join(
", ",
)}):\n return {${returns.join(", ")}}\n`
}
function PanelBody({
node,
flow,
@@ -699,6 +863,21 @@ function PanelBody({
}, 1000)
}
/**
* Change the node, and keep an untouched scaffold in step with its ports.
*
* Only code that is still exactly what this panel generates is rewritten —
* one edit of your own and it is yours, ports or no ports. Shared code is
* never rewritten either: the other flows using it have their own ports.
*/
const editNode = (next: NodeDef_Input) => {
onChange(next)
const current = code ?? source?.code
if (!hasSource || next.source_ref || current === undefined) return
const wanted = scaffoldFor(next)
if (current !== wanted && SCAFFOLD_SHAPE.test(current)) editCode(wanted)
}
// ⌘S in the editor means "apply this code"; the editor's own state is here,
// so the binding is too. Anywhere else on the canvas it publishes the flow.
useShortcuts(
@@ -729,7 +908,7 @@ function PanelBody({
flow={flow}
emptyHint="Nothing yet. Add a message this node reads."
suggestions={suggestions.consumes}
onChange={(requires) => onChange({ ...node, requires })}
onChange={(requires) => editNode({ ...node, requires })}
/>
<PortList
title="Provides"
@@ -737,12 +916,13 @@ function PanelBody({
flow={flow}
emptyHint="Nothing yet. Add a message this node publishes."
suggestions={suggestions.provides}
onChange={(provides) => onChange({ ...node, provides })}
onChange={(provides) => editNode({ ...node, provides })}
// Only the publishing side names a message; an input is as often
// re-pointed at a different one as it is renamed.
onRenamed={onRenameMessage}
/>
<ParamsForm
type={node.type}
schema={nodeType?.params_schema}
params={node.params ?? {}}
onChange={(params) => onChange({ ...node, params })}
+47
View File
@@ -1,6 +1,8 @@
import {
type UseMutationResult,
useMutation,
useQueries,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import { useCallback, useEffect, useRef, useState } from "react"
@@ -62,6 +64,51 @@ export const messageHistoryQueryOptions = (name: string, message: string) => ({
queryFn: () => FlowsService.readMessageHistory({ name, message }),
})
/** Other flows change rarely; suggestions do not need them fresh to the second. */
const SUGGEST_STALE = 5 * 60 * 1000
/**
* Values already in use by nodes of the same type, keyed by parameter.
*
* The second MQTT node points at the same broker as the first one, so the
* settings are worth offering rather than making someone type them again.
*
* Only plain values are collected: a secret is stored as a reference object
* and stays out of this, which is the point of the secret picker.
*/
export function useParamSuggestions(
type: string | undefined,
): Record<string, string[]> {
const { data: flows } = useQuery({
...flowsQueryOptions(),
staleTime: SUGGEST_STALE,
})
// ponytail: reads every flow to collect them, sharing the editor's own cache
// entries; an aggregate endpoint if a big installation makes that hurt.
const details = useQueries({
queries: (type ? (flows?.data ?? []) : []).map((flow) => ({
...flowQueryOptions(flow.name),
staleTime: SUGGEST_STALE,
})),
})
const seen: Record<string, Set<string>> = {}
for (const { data } of details) {
for (const node of data?.definition.nodes ?? []) {
if (node.type !== type) continue
for (const [key, value] of Object.entries(node.params ?? {})) {
if (value === null || value === "" || typeof value === "object")
continue
if (!seen[key]) seen[key] = new Set()
seen[key].add(String(value))
}
}
}
return Object.fromEntries(
Object.entries(seen).map(([key, values]) => [key, [...values].sort()]),
)
}
const AUTOSAVE_DELAY = 800
/** How long to wait for a save in flight before sending the next one. */
const RETRY_DELAY = 100