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.
This commit is contained in:
2026-10-06 07:24:43 +02:00
parent 409a90db28
commit b5513f8615
3 changed files with 69 additions and 10 deletions
+4 -1
View File
@@ -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
+32 -1
View File
@@ -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 (
<JsonField
id={id}
value={value}
label={label}
// Empty is no value here, as it is in the text field below.
placeholder={placeholder ?? ""}
check={(next) => shapeError(dtype, next)}
className={className}
onChange={onChange}
/>
)
}
return (
<Input
id={id}
+33 -8
View File
@@ -14,14 +14,18 @@ import { cn } from "@/lib/utils"
const NodeEditor = lazy(() => 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<string | null>(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. */}
<span className="w-0 flex-1 truncate">{JSON.stringify(value)}</span>
<span
className={cn(
"w-0 flex-1 truncate",
blank && "text-muted-foreground",
)}
>
{blank ? placeholder : JSON.stringify(value)}
</span>
<Maximize2 className="size-3.5 shrink-0 text-muted-foreground" />
</button>
@@ -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)
}}
>