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:
@@ -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 })}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user