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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user