Edit JSON node settings in a dialog from a one-line preview

A free setting typed json and a declared object or array setting (a Switch
node's rules, HTTP headers) show one line of their value; clicking opens a
Monaco JSON editor in a dialog that saves only once the text parses.
Declared object settings were not shown at all before. Escape in a dialog
opened from a side panel no longer closes the panel too.
This commit is contained in:
2026-10-05 23:49:42 +02:00
parent 6fe59347e0
commit 5a145b8f59
6 changed files with 192 additions and 14 deletions
+4 -1
View File
@@ -57,7 +57,10 @@ renamed.
**Settings** is generated from the node type's parameter schema, so an MQTT
node shows broker, topic and QoS, and an ntfy node shows server, topic and
priority. A field marked as a credential renders as a picker over your stored
[secrets](operations.md) instead of a text box.
[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.
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
+125
View File
@@ -0,0 +1,125 @@
import { Maximize2 } from "lucide-react"
import { lazy, Suspense, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
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 {
try {
JSON.parse(text)
return null
} catch (error) {
return (error as Error).message
}
}
/**
* A setting holding JSON: one line of it in the panel, the whole value in an
* editor a click away.
*
* The dialog edits a draft, so a half-typed value never reaches the node:
* Save stays off until the text parses.
*/
export function JsonField({
id,
value,
label,
className,
onChange,
}: {
id?: string
value: unknown
/** The setting's name, which titles the dialog. */
label: string
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)
return (
<>
<button
type="button"
id={id}
aria-label={`Edit ${label}`}
data-testid="json-field"
className={cn(
"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) ?? "")}
>
{/* No width of its own, so a long value never widens the panel. */}
<span className="w-0 flex-1 truncate">{JSON.stringify(value)}</span>
<Maximize2 className="size-3.5 shrink-0 text-muted-foreground" />
</button>
<Dialog
open={draft !== null}
onOpenChange={(open) => {
if (!open) setDraft(null)
}}
>
<DialogContent
data-testid="json-dialog"
className="flex h-[80dvh] flex-col sm:max-w-2xl"
// The editor takes the caret itself once it has loaded.
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle className="font-mono">{label}</DialogTitle>
<DialogDescription>JSON</DialogDescription>
</DialogHeader>
<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
language="json"
autoFocus
value={draft ?? ""}
onChange={setDraft}
/>
</Suspense>
</div>
{error ? (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setDraft(null)}>
Cancel
</Button>
<Button
disabled={error !== null}
data-testid="json-save"
onClick={() => {
if (draft === null) return
onChange(JSON.parse(draft))
setDraft(null)
}}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
+10 -4
View File
@@ -6,19 +6,24 @@ import { Skeleton } from "@/components/ui/skeleton"
import { monacoFontFamily, setupMonaco } from "./monacoSetup"
/**
* The embedded Python editor. Highlighting and bracket handling only: what the
* code actually does is checked by the engine, which reports load and run
* errors back onto the node.
* The embedded code editor, Python unless asked for JSON. Highlighting and
* bracket handling only: what the code actually does is checked by the engine,
* which reports load and run errors back onto the node.
*/
export default function NodeEditor({
value,
onChange,
language = "python",
readOnly = false,
autoFocus = false,
}: {
value: string
onChange: (next: string) => void
language?: "python" | "json"
/** Generated code: readable, and not this panel's to change. */
readOnly?: boolean
/** Take the caret once mounted, as an editor opened on purpose should. */
autoFocus?: boolean
}) {
const { resolvedTheme } = useTheme()
const [ready, setReady] = useState(false)
@@ -34,10 +39,11 @@ export default function NodeEditor({
return (
<Editor
language="python"
language={language}
theme={resolvedTheme === "dark" ? "fluksio-dark" : "fluksio-light"}
value={value}
onChange={(next) => onChange(next ?? "")}
onMount={autoFocus ? (editor) => editor.focus() : undefined}
loading={<Skeleton className="h-full w-full rounded-md" />}
options={{
readOnly,
+35 -4
View File
@@ -46,6 +46,7 @@ import useCustomToast from "@/hooks/useCustomToast"
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils"
import { DtypeValue } from "./FlowBoundary"
import { JsonField } from "./JsonField"
import { MessageSparkline } from "./MessageSparkline"
import {
flavorsQueryOptions,
@@ -496,9 +497,10 @@ function castTo(type: FreeType, raw: string): unknown {
if (type === "on/off") return raw === "true"
if (type === "json") {
try {
return JSON.parse(raw)
// An empty setting turned into JSON starts as an empty object.
return JSON.parse(raw || "{}")
} catch {
// Half-typed JSON is normal while editing; keep the text until it parses.
// Text that is not JSON stays text, and the type select says so.
return raw
}
}
@@ -615,6 +617,13 @@ function FreeParamsForm({
onChange({ ...params, [key]: checked })
}
/>
) : type === "json" ? (
<JsonField
value={value}
label={key}
className="flex-1"
onChange={(next) => onChange({ ...params, [key]: next })}
/>
) : (
<Input
value={asText(value)}
@@ -891,8 +900,30 @@ function ParamsForm({
)
}
if (property.type === "object" || property.type === "array") {
return null
if (
property.type === "object" ||
property.type === "array" ||
(value !== null && typeof value === "object")
) {
// An inject's per-port payloads are the fields drawn above.
if (type === "inject" && key === "payloads") return null
return (
<div key={key} className="grid gap-1.5">
<FieldLabel htmlFor={`param-${key}`} help={property.description}>
{label}
</FieldLabel>
<JsonField
id={`param-${key}`}
value={
params[key] ??
property.default ??
(property.type === "array" ? [] : {})
}
label={label}
onChange={(next) => set(key, next)}
/>
</div>
)
}
const numeric =
+3 -1
View File
@@ -228,7 +228,9 @@ export function SidePanel({
useEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose()
// A dialog opened from the panel closes on its own Escape and marks it
// handled; that one press should not take the panel with it.
if (event.key === "Escape" && !event.defaultPrevented) onClose()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
+15 -4
View File
@@ -1,15 +1,18 @@
/**
* Monaco, self-hosted and themed from the design tokens.
*
* Only the base editor and the Python grammar are pulled in; the other seventy
* languages would triple the chunk for nothing. This module is imported by
* NodeEditor alone, which the panel loads lazily.
* Only the base editor, the Python grammar and JSON (for settings holding JSON)
* are pulled in; the other seventy languages would triple the chunk for
* nothing. This module is imported by NodeEditor alone, which the panel loads
* lazily.
*/
import { loader } from "@monaco-editor/react"
import * as monaco from "monaco-editor/editor/editor.api"
// Only Python registers itself; `basic-languages` would pull in all seventy.
import "monaco-editor/languages/definitions/python/register"
import "monaco-editor/languages/features/json/register"
import editorWorker from "monaco-editor/editor/editor.worker?worker"
import jsonWorker from "monaco-editor/languages/features/json/json.worker?worker"
let configured = false
@@ -36,6 +39,11 @@ function defineThemes() {
{ token: "string", foreground: success.slice(1) },
{ token: "comment", foreground: muted.slice(1), fontStyle: "italic" },
{ token: "number", foreground: foreground.slice(1) },
// JSON names its tokens more specifically, so the base theme's
// colours would win over the generic ones above.
{ token: "string.key.json", foreground: foreground.slice(1) },
{ token: "string.value.json", foreground: success.slice(1) },
{ token: "keyword.json", foreground: primary.slice(1) },
],
colors: {
"editor.background": background,
@@ -58,7 +66,10 @@ export function setupMonaco(): typeof monaco {
configured = true
// Self-hosted: no CDN fetch, so the editor works on a LAN install.
self.MonacoEnvironment = { getWorker: () => new editorWorker() }
self.MonacoEnvironment = {
getWorker: (_, label) =>
label === "json" ? new jsonWorker() : new editorWorker(),
}
loader.config({ monaco })
defineThemes()
return monaco