diff --git a/docs/interface/flow-editor.md b/docs/interface/flow-editor.md index cc40063..102b0c5 100644 --- a/docs/interface/flow-editor.md +++ b/docs/interface/flow-editor.md @@ -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 diff --git a/frontend/src/components/Flow/JsonField.tsx b/frontend/src/components/Flow/JsonField.tsx new file mode 100644 index 0000000..6f3b68f --- /dev/null +++ b/frontend/src/components/Flow/JsonField.tsx @@ -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(null) + const error = draft === null ? null : parseError(draft) + + return ( + <> + + + { + if (!open) setDraft(null) + }} + > + event.preventDefault()} + > + + {label} + JSON + +
+ + } + > + + +
+ {error ? ( +

+ {error} +

+ ) : null} + + + + +
+
+ + ) +} diff --git a/frontend/src/components/Flow/NodeEditor.tsx b/frontend/src/components/Flow/NodeEditor.tsx index 239845b..f0fc50f 100644 --- a/frontend/src/components/Flow/NodeEditor.tsx +++ b/frontend/src/components/Flow/NodeEditor.tsx @@ -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 ( onChange(next ?? "")} + onMount={autoFocus ? (editor) => editor.focus() : undefined} loading={} options={{ readOnly, diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index 0115b90..6e5e206 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -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" ? ( + onChange({ ...params, [key]: next })} + /> ) : ( + + {label} + + set(key, next)} + /> + + ) } const numeric = diff --git a/frontend/src/components/Flow/SidePanel.tsx b/frontend/src/components/Flow/SidePanel.tsx index 9ff65b0..c2636ea 100644 --- a/frontend/src/components/Flow/SidePanel.tsx +++ b/frontend/src/components/Flow/SidePanel.tsx @@ -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) diff --git a/frontend/src/components/Flow/monacoSetup.ts b/frontend/src/components/Flow/monacoSetup.ts index 13b440e..0276b54 100644 --- a/frontend/src/components/Flow/monacoSetup.ts +++ b/frontend/src/components/Flow/monacoSetup.ts @@ -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