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. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
@@ -1,6 +1,13 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { Maximize2, Minimize2, X } from "lucide-react"
|
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 {
|
import {
|
||||||
type DType,
|
type DType,
|
||||||
@@ -37,6 +44,7 @@ import {
|
|||||||
libraryQueryOptions,
|
libraryQueryOptions,
|
||||||
nodeSourceQueryOptions,
|
nodeSourceQueryOptions,
|
||||||
secretsQueryOptions,
|
secretsQueryOptions,
|
||||||
|
useParamSuggestions,
|
||||||
} from "./queries"
|
} from "./queries"
|
||||||
import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
|
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. */
|
/** Settings the engine reads itself, so they are not the author's to name. */
|
||||||
const RESERVED_PARAMS = new Set(["synchronous"])
|
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.
|
* 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. */
|
/** The name as it was before this edit, once the field is done with. */
|
||||||
onRenamed?: (previous: string, next: string) => void
|
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
|
// Every keystroke commits, so a rename is only a rename once the user is
|
||||||
// finished with the field.
|
// finished with the field.
|
||||||
const before = useRef(value)
|
const before = useRef(value)
|
||||||
const matches = suggestions.filter(
|
|
||||||
(name) =>
|
|
||||||
name !== value && name.toLowerCase().includes(value.toLowerCase()),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open && matches.length > 0} onOpenChange={setOpen}>
|
<SuggestInput
|
||||||
<PopoverAnchor asChild>
|
|
||||||
<Input
|
|
||||||
value={value}
|
value={value}
|
||||||
|
suggestions={suggestions}
|
||||||
|
empty="No matching message."
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
aria-label="Message name"
|
aria-label="Message name"
|
||||||
autoComplete="off"
|
|
||||||
// A port added by hand is meant to be named right away.
|
// A port added by hand is meant to be named right away.
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
className="h-8 flex-1 font-mono text-sm"
|
className="h-8 flex-1 font-mono text-sm"
|
||||||
|
onChange={onChange}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
before.current = value
|
before.current = value
|
||||||
setOpen(true)
|
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setOpen(false)
|
|
||||||
if (before.current !== value) onRenamed?.(before.current, value)
|
if (before.current !== value) onRenamed?.(before.current, value)
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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. */
|
/** A small form built from the node type's declared parameters. */
|
||||||
function ParamsForm({
|
function ParamsForm({
|
||||||
|
type,
|
||||||
schema,
|
schema,
|
||||||
params,
|
params,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
|
type: string | undefined
|
||||||
schema: Record<string, unknown> | undefined
|
schema: Record<string, unknown> | undefined
|
||||||
params: Record<string, unknown>
|
params: Record<string, unknown>
|
||||||
onChange: (next: Record<string, unknown>) => void
|
onChange: (next: Record<string, unknown>) => void
|
||||||
}) {
|
}) {
|
||||||
const { data: secretList } = useQuery(secretsQueryOptions())
|
|
||||||
const secrets = secretList?.data ?? []
|
|
||||||
const properties = (schema?.properties ?? {}) as Record<
|
const properties = (schema?.properties ?? {}) as Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -446,6 +550,12 @@ function ParamsForm({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
const entries = Object.entries(properties)
|
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
|
if (entries.length === 0) return null
|
||||||
|
|
||||||
const set = (key: string, value: unknown) =>
|
const set = (key: string, value: unknown) =>
|
||||||
@@ -519,23 +629,26 @@ function ParamsForm({
|
|||||||
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
|
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<SuggestInput
|
||||||
id={`param-${key}`}
|
id={`param-${key}`}
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
type={numeric ? "number" : "text"}
|
type={numeric ? "number" : "text"}
|
||||||
value={String(value)}
|
value={String(value)}
|
||||||
onChange={(event) =>
|
suggestions={suggestions[key] ?? []}
|
||||||
set(
|
empty="Nothing like that in use yet."
|
||||||
key,
|
onChange={(next) => set(key, numeric ? Number(next) : next)}
|
||||||
numeric ? Number(event.target.value) : event.target.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{property.description ? (
|
{property.description ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{property.description}
|
{property.description}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{key === "cron" ? (
|
||||||
|
<CronHelp
|
||||||
|
params={params}
|
||||||
|
onPick={(expression) => set(key, expression)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</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({
|
function PanelBody({
|
||||||
node,
|
node,
|
||||||
flow,
|
flow,
|
||||||
@@ -699,6 +863,21 @@ function PanelBody({
|
|||||||
}, 1000)
|
}, 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,
|
// ⌘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.
|
// so the binding is too. Anywhere else on the canvas it publishes the flow.
|
||||||
useShortcuts(
|
useShortcuts(
|
||||||
@@ -729,7 +908,7 @@ function PanelBody({
|
|||||||
flow={flow}
|
flow={flow}
|
||||||
emptyHint="Nothing yet. Add a message this node reads."
|
emptyHint="Nothing yet. Add a message this node reads."
|
||||||
suggestions={suggestions.consumes}
|
suggestions={suggestions.consumes}
|
||||||
onChange={(requires) => onChange({ ...node, requires })}
|
onChange={(requires) => editNode({ ...node, requires })}
|
||||||
/>
|
/>
|
||||||
<PortList
|
<PortList
|
||||||
title="Provides"
|
title="Provides"
|
||||||
@@ -737,12 +916,13 @@ function PanelBody({
|
|||||||
flow={flow}
|
flow={flow}
|
||||||
emptyHint="Nothing yet. Add a message this node publishes."
|
emptyHint="Nothing yet. Add a message this node publishes."
|
||||||
suggestions={suggestions.provides}
|
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
|
// Only the publishing side names a message; an input is as often
|
||||||
// re-pointed at a different one as it is renamed.
|
// re-pointed at a different one as it is renamed.
|
||||||
onRenamed={onRenameMessage}
|
onRenamed={onRenameMessage}
|
||||||
/>
|
/>
|
||||||
<ParamsForm
|
<ParamsForm
|
||||||
|
type={node.type}
|
||||||
schema={nodeType?.params_schema}
|
schema={nodeType?.params_schema}
|
||||||
params={node.params ?? {}}
|
params={node.params ?? {}}
|
||||||
onChange={(params) => onChange({ ...node, params })}
|
onChange={(params) => onChange({ ...node, params })}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
type UseMutationResult,
|
type UseMutationResult,
|
||||||
useMutation,
|
useMutation,
|
||||||
|
useQueries,
|
||||||
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query"
|
} from "@tanstack/react-query"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
@@ -62,6 +64,51 @@ export const messageHistoryQueryOptions = (name: string, message: string) => ({
|
|||||||
queryFn: () => FlowsService.readMessageHistory({ name, message }),
|
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
|
const AUTOSAVE_DELAY = 800
|
||||||
/** How long to wait for a save in flight before sending the next one. */
|
/** How long to wait for a save in flight before sending the next one. */
|
||||||
const RETRY_DELAY = 100
|
const RETRY_DELAY = 100
|
||||||
|
|||||||
Reference in New Issue
Block a user