From abc71e481e46e162d594ce17199f12cea1d881e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 11:43:51 +0000 Subject: [PATCH] =?UTF-8?q?fix(editor):=20NodeEditorDialog=20=E2=80=94=20c?= =?UTF-8?q?orrect=20bracket-form=20path=20wrapping=20+=20focus=20trap=20on?= =?UTF-8?q?=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/editor/NodeEditorDialog.test.tsx | 31 ++++++++++++++ frontend/src/editor/NodeEditorDialog.tsx | 42 ++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/frontend/src/editor/NodeEditorDialog.test.tsx b/frontend/src/editor/NodeEditorDialog.test.tsx index 7feda98..9e2de68 100644 --- a/frontend/src/editor/NodeEditorDialog.test.tsx +++ b/frontend/src/editor/NodeEditorDialog.test.tsx @@ -192,6 +192,37 @@ describe("NodeEditorDialog", () => { expect(props.onClose).toHaveBeenCalled(); }); + it("focuses the first form field on open", async () => { + renderDialog(); + // jsdom autofocuses via .focus(); label textarea should be active. + const label = screen.getByLabelText(/node label/i); + expect(document.activeElement).toBe(label); + }); + + it("bracket-form top-level keys wrap without a stray dot separator", async () => { + server.use( + http.get("/api/workflows/:id/executions", () => + HttpResponse.json([ + { id: "e1", workflowId: "wf1", trigger: "manual", status: "completed", + startedAt: "", endedAt: "", error: null } satisfies ExecutionSummary, + ])), + http.get("/api/executions/e1", () => + HttpResponse.json({ + id: "e1", workflowId: "wf1", trigger: "manual", status: "completed", + startedAt: "", endedAt: "", error: null, + nodes: [ + { nodeId: "n1", status: "completed", startedAt: "", endedAt: "", error: null, + inputs: [[{ json: { "weird.key": "v" } }]], outputs: [] }, + ], + } satisfies ExecutionDetail)), + ); + renderControlledDialog(); + const body = await screen.findByLabelText(/^body/i) as HTMLTextAreaElement; + body.focus(); + await userEvent.click(await screen.findByText("weird.key")); + expect(body.value).toBe(`{input["weird.key"]}`); + }); + it("clicking a leaf in the Input tree inserts {input.} at the focused parameter", async () => { // Seed an execution so the input tree has a leaf. server.use( diff --git a/frontend/src/editor/NodeEditorDialog.tsx b/frontend/src/editor/NodeEditorDialog.tsx index 2e24edf..08cec0a 100644 --- a/frontend/src/editor/NodeEditorDialog.tsx +++ b/frontend/src/editor/NodeEditorDialog.tsx @@ -39,6 +39,41 @@ export function NodeEditorDialog({ return () => document.removeEventListener("keydown", onKey); }, [onClose]); + // Initial focus on mount — the first field inside the dialog body. + useEffect(() => { + const first = dialogRef.current?.querySelector( + "textarea, input, select" + ); + first?.focus(); + }, []); + + // Focus trap: Tab / Shift+Tab cycle within the dialog. jsdom doesn't + // simulate real browser tab-order, so this behavior is intentionally + // exercised only in-browser — no unit test covers the wrap-around. + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key !== "Tab" || !dialogRef.current) return; + const focusables = Array.from( + dialogRef.current.querySelectorAll( + 'textarea, input, select, button, [tabindex]:not([tabindex="-1"])' + ), + ).filter((el) => !el.hasAttribute("disabled")); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement as HTMLElement | null; + if (e.shiftKey && active === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, []); + const executions = useExecutions(workflowId); const [pinnedExecutionId, setPinnedExecutionId] = useState(null); const eid = pinnedExecutionId ?? executions.data?.[0]?.id; @@ -78,7 +113,12 @@ export function NodeEditorDialog({ focusedFieldRef.current = h; }, []); const insertPath = useCallback((path: string) => { - focusedFieldRef.current?.insertAtCaret(`{input.${path}}`); + // JsonTree emits identifier-rooted paths (`body.email`, `tags[1]`) or + // bracket-rooted paths (`["weird.key"]`, `[0].a`). Backend TemplateEngine + // grammar requires no separator before a leading `[` (i.e., `{input[0]}`, + // not `{input.[0]}`). Insert `.` only for identifier roots. + const sep = path.startsWith("[") ? "" : "."; + focusedFieldRef.current?.insertAtCaret(`{input${sep}${path}}`); }, []); const filteredConnections = connections.filter((c) => c.providerId === nt?.connectionProviderId);