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.
869 lines
26 KiB
TypeScript
869 lines
26 KiB
TypeScript
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 DType,
|
|
FlowsService,
|
|
type MessageSpec,
|
|
type NodeDef_Input,
|
|
type NodeTypeInfo,
|
|
} from "@/client"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import { Switch } from "@/components/ui/switch"
|
|
import useCustomToast from "@/hooks/useCustomToast"
|
|
import { cn } from "@/lib/utils"
|
|
import { MessageSparkline } from "./MessageSparkline"
|
|
import {
|
|
flowKeys,
|
|
libraryQueryOptions,
|
|
nodeSourceQueryOptions,
|
|
secretsQueryOptions,
|
|
} from "./queries"
|
|
import { PANEL_SECTION, SidePanel } from "./SidePanel"
|
|
|
|
const NodeEditor = lazy(() => import("./NodeEditor"))
|
|
|
|
const DTYPES: DType[] = ["float", "int", "str", "bool", "json"]
|
|
|
|
/** Radix selects cannot hold an empty value, so "no secret" needs a name. */
|
|
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.
|
|
*
|
|
* The suggestions are the point: a message only connects when both ends spell
|
|
* it the same way, so choosing beats typing.
|
|
*/
|
|
function MessageNameInput({
|
|
value,
|
|
suggestions,
|
|
placeholder,
|
|
autoFocus,
|
|
onChange,
|
|
onRenamed,
|
|
}: {
|
|
value: string
|
|
suggestions: string[]
|
|
placeholder: string
|
|
autoFocus: boolean
|
|
onChange: (next: string) => void
|
|
/** 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>
|
|
)
|
|
}
|
|
|
|
function PortList({
|
|
title,
|
|
specs,
|
|
flow,
|
|
emptyHint,
|
|
suggestions,
|
|
onChange,
|
|
onRenamed,
|
|
}: {
|
|
title: string
|
|
specs: MessageSpec[]
|
|
flow: string
|
|
emptyHint: string
|
|
suggestions: string[]
|
|
onChange: (next: MessageSpec[]) => void
|
|
onRenamed?: (previous: string, next: string) => void
|
|
}) {
|
|
// The port just added, so its name field can take focus.
|
|
const [freshIndex, setFreshIndex] = useState<number | null>(null)
|
|
|
|
const update = (index: number, patch: Partial<MessageSpec>) => {
|
|
const next = specs.map((spec, i) =>
|
|
i === index ? { ...spec, ...patch } : spec,
|
|
)
|
|
onChange(next)
|
|
}
|
|
|
|
return (
|
|
<div className="grid gap-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className={SECTION}>{title}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 text-xs text-muted-foreground"
|
|
onClick={() => {
|
|
setFreshIndex(specs.length)
|
|
onChange([...specs, { name: "", dtype: "float" }])
|
|
}}
|
|
>
|
|
Add
|
|
</Button>
|
|
</div>
|
|
|
|
{specs.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">{emptyHint}</p>
|
|
) : null}
|
|
|
|
{specs.map((spec, index) => (
|
|
<div key={`port-${index}`} className="grid gap-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<MessageNameInput
|
|
value={spec.name ?? ""}
|
|
suggestions={suggestions}
|
|
placeholder={`name in ${flow}`}
|
|
autoFocus={index === freshIndex}
|
|
onChange={(name) => update(index, { name, port: "" })}
|
|
onRenamed={onRenamed}
|
|
/>
|
|
<Select
|
|
value={spec.dtype ?? "float"}
|
|
onValueChange={(value) =>
|
|
update(index, { dtype: value as DType })
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
className="!h-8 w-[92px] text-sm"
|
|
aria-label="Type"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DTYPES.map((dtype) => (
|
|
<SelectItem key={dtype} value={dtype}>
|
|
{dtype}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
step="any"
|
|
value={spec.interval ? String(spec.interval) : ""}
|
|
placeholder="∞"
|
|
aria-label="Deliver at most every n seconds"
|
|
title="Deliver at most every n seconds; empty is every time"
|
|
className="h-8 w-16 text-sm"
|
|
onChange={(event) =>
|
|
update(index, { interval: Number(event.target.value) || 0 })
|
|
}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="text-muted-foreground"
|
|
aria-label="Remove port"
|
|
onClick={() => onChange(specs.filter((_, i) => i !== index))}
|
|
>
|
|
<X />
|
|
</Button>
|
|
</div>
|
|
{spec.name ? <MessageSparkline flow={flow} name={spec.name} /> : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 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,
|
|
params,
|
|
onChange,
|
|
}: {
|
|
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,
|
|
{
|
|
type?: string
|
|
title?: string
|
|
description?: string
|
|
default?: unknown
|
|
"x-secret"?: boolean
|
|
}
|
|
>
|
|
const entries = Object.entries(properties)
|
|
if (entries.length === 0) return null
|
|
|
|
const set = (key: string, value: unknown) =>
|
|
onChange({ ...params, [key]: value })
|
|
|
|
return (
|
|
<div className="grid gap-3">
|
|
<span className={SECTION}>Settings</span>
|
|
{entries.map(([key, property]) => {
|
|
const value = params[key] ?? property.default ?? ""
|
|
const label = property.title ?? key
|
|
|
|
if (property.type === "boolean") {
|
|
return (
|
|
<div key={key} className="flex items-center justify-between gap-2">
|
|
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
|
|
{label}
|
|
</Label>
|
|
<Switch
|
|
id={`param-${key}`}
|
|
checked={Boolean(value)}
|
|
onCheckedChange={(checked) => set(key, checked)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// A credential is stored once and referenced, so it never ends up in
|
|
// flow.json where the whole team can read it.
|
|
if (property["x-secret"]) {
|
|
const reference = (params[key] ?? null) as { $secret?: string } | null
|
|
return (
|
|
<div key={key} className="grid gap-1.5">
|
|
<Label className="text-sm font-normal">{label}</Label>
|
|
<Select
|
|
value={reference?.$secret ?? ""}
|
|
onValueChange={(name) =>
|
|
set(key, name === NO_SECRET ? null : { $secret: name })
|
|
}
|
|
>
|
|
<SelectTrigger className="!h-8 text-sm" aria-label={label}>
|
|
<SelectValue placeholder="Pick a stored secret" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NO_SECRET}>None</SelectItem>
|
|
{secrets.map((secret) => (
|
|
<SelectItem key={secret} value={secret}>
|
|
{secret}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
{secrets.length
|
|
? property.description ||
|
|
"Stored in the secrets store, never in the flow file."
|
|
: "No secrets stored yet."}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (property.type === "object" || property.type === "array") {
|
|
return null
|
|
}
|
|
|
|
const numeric =
|
|
property.type === "integer" || property.type === "number"
|
|
return (
|
|
<div key={key} className="grid gap-1.5">
|
|
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
|
|
{label}
|
|
</Label>
|
|
<Input
|
|
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,
|
|
)
|
|
}
|
|
/>
|
|
{property.description ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
{property.description}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Sharing a node moves its code to the library, where other flows can point at
|
|
* it. Each flow keeps its own ports and settings; only the code is common, so
|
|
* one fix reaches all of them.
|
|
*/
|
|
function SharingSection({
|
|
flow,
|
|
node,
|
|
onShared,
|
|
}: {
|
|
flow: string
|
|
node: NodeDef_Input
|
|
onShared: () => void
|
|
}) {
|
|
const queryClient = useQueryClient()
|
|
const { showErrorToast } = useCustomToast()
|
|
const { data: library } = useQuery(libraryQueryOptions())
|
|
const [name, setName] = useState("")
|
|
|
|
const shared = node.source_ref
|
|
const usages = library?.find((entry) => entry.name === shared)?.used_by ?? []
|
|
|
|
const done = () => {
|
|
queryClient.invalidateQueries({ queryKey: flowKeys.library })
|
|
queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow) })
|
|
onShared()
|
|
}
|
|
|
|
const share = useMutation({
|
|
mutationFn: (libName: string) =>
|
|
FlowsService.shareNode({
|
|
name: flow,
|
|
nodeId: node.id,
|
|
requestBody: { lib_name: libName },
|
|
}),
|
|
onSuccess: done,
|
|
onError: () =>
|
|
showErrorToast("That name is taken, or is not a valid name."),
|
|
})
|
|
|
|
const unshare = useMutation({
|
|
mutationFn: () => FlowsService.unshareNode({ name: flow, nodeId: node.id }),
|
|
onSuccess: done,
|
|
onError: () => showErrorToast("The node could not be unshared."),
|
|
})
|
|
|
|
if (shared) {
|
|
return (
|
|
<div className="grid gap-2">
|
|
<span className={SECTION}>Shared</span>
|
|
<p className="text-sm text-muted-foreground">
|
|
Runs <span className="font-mono">{shared}</span> from the library
|
|
{usages.length > 1
|
|
? `, along with ${usages.length - 1} other node${
|
|
usages.length === 2 ? "" : "s"
|
|
}`
|
|
: ""}
|
|
. Editing the code here changes it everywhere.
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 justify-self-start"
|
|
disabled={unshare.isPending}
|
|
onClick={() => unshare.mutate()}
|
|
data-testid="unshare-node"
|
|
>
|
|
Keep a private copy
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="grid gap-2">
|
|
<span className={SECTION}>Reuse</span>
|
|
<p className="text-sm text-muted-foreground">
|
|
Move this node's code to the library so other flows can run it too.
|
|
</p>
|
|
<div className="flex items-center gap-1.5">
|
|
<Input
|
|
value={name}
|
|
placeholder="read_temperature"
|
|
aria-label="Shared name"
|
|
autoComplete="off"
|
|
className="h-8 flex-1 font-mono text-sm"
|
|
onChange={(event) => setName(event.target.value)}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
className="h-8"
|
|
disabled={!name || share.isPending}
|
|
onClick={() => share.mutate(name)}
|
|
data-testid="share-node"
|
|
>
|
|
Share
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PanelBody({
|
|
node,
|
|
flow,
|
|
nodeType,
|
|
suggestions,
|
|
expanded,
|
|
onChange,
|
|
onRenameMessage,
|
|
onSaveSource,
|
|
onShared,
|
|
onToggleExpand,
|
|
}: {
|
|
node: NodeDef_Input
|
|
flow: string
|
|
nodeType: NodeTypeInfo | undefined
|
|
suggestions: PortSuggestions
|
|
expanded: boolean
|
|
onChange: (next: NodeDef_Input) => void
|
|
onRenameMessage: (previous: string, next: string) => void
|
|
onSaveSource: (code: string) => void
|
|
onShared: () => void
|
|
onToggleExpand: () => void
|
|
}) {
|
|
const hasSource = nodeType?.has_source ?? node.type === "python"
|
|
const { data: source } = useQuery({
|
|
...nodeSourceQueryOptions(flow, node.id),
|
|
enabled: hasSource,
|
|
})
|
|
|
|
const [code, setCode] = useState<string | null>(null)
|
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const pending = useRef<string | null>(null)
|
|
const save = useRef(onSaveSource)
|
|
save.current = onSaveSource
|
|
|
|
const editCode = (next: string) => {
|
|
setCode(next)
|
|
pending.current = next
|
|
if (timer.current) clearTimeout(timer.current)
|
|
timer.current = setTimeout(() => {
|
|
timer.current = null
|
|
save.current(next)
|
|
}, 1000)
|
|
}
|
|
|
|
// Closing the panel must not lose the last keystrokes.
|
|
useEffect(() => {
|
|
return () => {
|
|
if (timer.current) {
|
|
clearTimeout(timer.current)
|
|
if (pending.current !== null) save.current(pending.current)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<>
|
|
<div className={cn("grid gap-5 p-4", expanded && "max-w-2xl")}>
|
|
<PortList
|
|
title="Consumes"
|
|
specs={node.requires ?? []}
|
|
flow={flow}
|
|
emptyHint="Nothing yet. Add a message this node reads."
|
|
suggestions={suggestions.consumes}
|
|
onChange={(requires) => onChange({ ...node, requires })}
|
|
/>
|
|
<PortList
|
|
title="Provides"
|
|
specs={node.provides ?? []}
|
|
flow={flow}
|
|
emptyHint="Nothing yet. Add a message this node publishes."
|
|
suggestions={suggestions.provides}
|
|
onChange={(provides) => onChange({ ...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
|
|
schema={nodeType?.params_schema}
|
|
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}
|
|
</div>
|
|
|
|
{hasSource ? (
|
|
<div className="flex min-h-[280px] flex-1 flex-col gap-2 px-4 pb-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className={SECTION}>
|
|
{node.source_ref ? `Shared code · ${node.source_ref}` : "Code"}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="hidden text-muted-foreground md:inline-flex"
|
|
onClick={onToggleExpand}
|
|
aria-label={
|
|
expanded ? "Collapse the editor" : "Expand the editor"
|
|
}
|
|
data-testid="toggle-editor-size"
|
|
>
|
|
{expanded ? <Minimize2 /> : <Maximize2 />}
|
|
</Button>
|
|
</div>
|
|
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border">
|
|
<Suspense
|
|
fallback={
|
|
<div className="h-full w-full animate-pulse bg-muted" />
|
|
}
|
|
>
|
|
<NodeEditor
|
|
value={code ?? source?.code ?? ""}
|
|
onChange={editCode}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Node settings, floating over the canvas so the graph stays visible and live.
|
|
* On a phone there is no room for that, so it becomes a full-screen sheet.
|
|
*/
|
|
/** Message names worth offering on each side of a node. */
|
|
export type PortSuggestions = { consumes: string[]; provides: string[] }
|
|
|
|
export function NodePanel({
|
|
node,
|
|
flow,
|
|
nodeTypes,
|
|
suggestions,
|
|
expanded,
|
|
onChange,
|
|
onRenameMessage,
|
|
onSaveSource,
|
|
onShared,
|
|
onToggleExpand,
|
|
onClose,
|
|
onDelete,
|
|
}: {
|
|
node: NodeDef_Input | null
|
|
flow: string
|
|
nodeTypes: NodeTypeInfo[]
|
|
suggestions: PortSuggestions
|
|
expanded: boolean
|
|
onChange: (next: NodeDef_Input) => void
|
|
onRenameMessage: (previous: string, next: string) => void
|
|
onSaveSource: (code: string) => void
|
|
onShared: () => void
|
|
onToggleExpand: () => void
|
|
onClose: () => void
|
|
onDelete: () => void
|
|
}) {
|
|
const nodeType = nodeTypes.find((entry) => entry.type === node?.type)
|
|
|
|
return (
|
|
<SidePanel
|
|
open={Boolean(node)}
|
|
label="Node settings"
|
|
testId="node-panel"
|
|
bodyKey={node?.id ?? "none"}
|
|
expanded={expanded}
|
|
onClose={onClose}
|
|
header={
|
|
node ? (
|
|
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
|
{/* Where the node lives: the flow is the namespace of its
|
|
messages, so it reads like the folder holding them. */}
|
|
<span className="min-w-0 truncate font-mono text-sm text-muted-foreground">
|
|
/{flow}/
|
|
</span>
|
|
<Input
|
|
value={node.title || node.id}
|
|
aria-label="Node name"
|
|
className="h-8 flex-1 text-sm font-medium"
|
|
onChange={(event) =>
|
|
onChange({ ...node, title: event.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
) : null
|
|
}
|
|
footer={
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={onDelete}
|
|
>
|
|
Delete node
|
|
</Button>
|
|
}
|
|
>
|
|
{node ? (
|
|
<PanelBody
|
|
node={node}
|
|
flow={flow}
|
|
nodeType={nodeType}
|
|
suggestions={suggestions}
|
|
expanded={expanded}
|
|
onChange={onChange}
|
|
onRenameMessage={onRenameMessage}
|
|
onSaveSource={onSaveSource}
|
|
onShared={onShared}
|
|
onToggleExpand={onToggleExpand}
|
|
/>
|
|
) : null}
|
|
</SidePanel>
|
|
)
|
|
}
|