From 457b88ed14ba21ed245af30e621236ab38747b4e Mon Sep 17 00:00:00 2001 From: stroblme Date: Tue, 6 Oct 2026 07:24:43 +0200 Subject: [PATCH] Edit json, record and list values in the JSON dialog An inject's payload and a flow input's starting value of those types use JsonField instead of a one-line text field. The field can now hold no value (shown as the placeholder; an emptied editor saves null) and checks the shape: a record must be an object, a list an array. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ --- docs/interface/flow-editor.md | 5 ++- frontend/src/components/Flow/FlowBoundary.tsx | 33 ++++++++++++++- frontend/src/components/Flow/JsonField.tsx | 41 +++++++++++++++---- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/docs/interface/flow-editor.md b/docs/interface/flow-editor.md index 102b0c5..14b2828 100644 --- a/docs/interface/flow-editor.md +++ b/docs/interface/flow-editor.md @@ -60,7 +60,10 @@ priority. A field marked as a credential renders as a picker over your stored [secrets](operations.md) instead of a text box. A setting holding JSON, such as a Switch node's rules, shows one line of it; click it to edit the whole value in a larger editor, which saves only once the text parses. Escape or Cancel -leaves the setting as it was. +leaves the setting as it was. An inject's payload and a flow input's starting +value of type `json`, `record` or `list` open the same editor, which also holds +the shape (a `record` is an object, a `list` an array); emptying it leaves no +value. A Function node gets a second settings section where you add your own: a setting is a constant of this node's code and arrives as an argument beside its diff --git a/frontend/src/components/Flow/FlowBoundary.tsx b/frontend/src/components/Flow/FlowBoundary.tsx index 4dedcde..5374db9 100644 --- a/frontend/src/components/Flow/FlowBoundary.tsx +++ b/frontend/src/components/Flow/FlowBoundary.tsx @@ -20,6 +20,7 @@ import { } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" import { qualify } from "./deriveEdges" +import { JsonField } from "./JsonField" import { useLiveValue } from "./liveStore" import { asText, DTYPES } from "./NodePanel" import { InfoTip, PANEL_SECTION } from "./SidePanel" @@ -51,10 +52,25 @@ export function parseByDtype(dtype: DType | undefined, raw: string): unknown { } } +/** Why a parsed value is not the shape its type declares, or null. */ +function shapeError(dtype: DType | undefined, value: unknown): string | null { + if ( + dtype === "record" && + (value === null || typeof value !== "object" || Array.isArray(value)) + ) { + return "A record is an object: { … }" + } + if (dtype === "list" && !Array.isArray(value)) { + return "A list is an array: [ … ]" + } + return null +} + /** * One literal, entered the way its type is entered. * - * A flag has two values and gets a choice of them; everything else is typed + * A flag has two values and gets a choice of them; JSON, a record and a list + * get the JSON editor, which also holds their shape; everything else is typed * and read back with {@link parseByDtype}. Deliberately not `type="number"` * for the numbers — see that function on why half-typed input must survive. * @@ -99,6 +115,21 @@ export function DtypeValue({ ) } + if (dtype === "json" || dtype === "record" || dtype === "list") { + return ( + shapeError(dtype, next)} + className={className} + onChange={onChange} + /> + ) + } + return ( import("./NodeEditor")) -/** Why `text` is not JSON, or null when it is. */ -function parseError(text: string): string | null { +/** Why `text` is not JSON of the right shape, or null when it is. */ +function parseError( + text: string, + check?: (value: unknown) => string | null, +): string | null { + let value: unknown try { - JSON.parse(text) - return null + value = JSON.parse(text) } catch (error) { return (error as Error).message } + return check?.(value) ?? null } /** @@ -35,6 +39,8 @@ export function JsonField({ id, value, label, + placeholder, + check, className, onChange, }: { @@ -42,12 +48,22 @@ export function JsonField({ value: unknown /** The setting's name, which titles the dialog. */ label: string + /** + * What having no value means, shown while there is none. Given, emptying + * the editor saves no value (null) instead of refusing to parse. + */ + placeholder?: string + /** Why a parsed value is the wrong shape, or null when it fits. */ + check?: (value: unknown) => string | null className?: string onChange: (next: unknown) => void }) { // The text being edited; null while the dialog is shut. const [draft, setDraft] = useState(null) - const error = draft === null ? null : parseError(draft) + const nullable = placeholder !== undefined + const blank = nullable && (value === null || value === undefined) + const emptied = nullable && draft?.trim() === "" + const error = draft === null || emptied ? null : parseError(draft, check) return ( <> @@ -60,10 +76,19 @@ export function JsonField({ "flex h-8 min-w-0 items-center gap-2 rounded-full border border-input bg-transparent px-3 text-left font-mono text-sm outline-none transition-[color,box-shadow] hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30", className, )} - onClick={() => setDraft(JSON.stringify(value, null, 2) ?? "")} + onClick={() => + setDraft(blank ? "" : (JSON.stringify(value, null, 2) ?? "")) + } > {/* No width of its own, so a long value never widens the panel. */} - {JSON.stringify(value)} + + {blank ? placeholder : JSON.stringify(value)} + @@ -111,7 +136,7 @@ export function JsonField({ data-testid="json-save" onClick={() => { if (draft === null) return - onChange(JSON.parse(draft)) + onChange(emptied ? null : JSON.parse(draft)) setDraft(null) }} >