feat(editor): PinnedDataSection — JSON editor + load-from-execution dropdown

This commit is contained in:
Claude
2026-08-30 14:32:32 +00:00
parent a8d2cbdab0
commit 3842eca8cd
2 changed files with 287 additions and 0 deletions

View File

@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { server } from "@/testing/msw";
import type { ExecutionDetail, ExecutionSummary } from "@/api/types";
import { PinnedDataSection } from "./PinnedDataSection";
function renderSection(
overrides: Partial<React.ComponentProps<typeof PinnedDataSection>> = {},
) {
const props: React.ComponentProps<typeof PinnedDataSection> = {
title: "Pinned input",
workflowId: "wf1",
nodeId: "n1",
side: "inputs",
value: null,
onChange: vi.fn(),
...overrides,
};
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const utils = render(
<QueryClientProvider client={qc}>
<PinnedDataSection {...props} />
</QueryClientProvider>,
);
return { ...utils, props };
}
beforeEach(() => {
// Default: no executions available. Individual tests override with
// `server.use(...)` when they need the load-from-execution flow.
server.use(
http.get("/api/workflows/:id/executions", () =>
HttpResponse.json([] as ExecutionSummary[])),
);
});
describe("PinnedDataSection", () => {
it("renders the passed title as the section heading", () => {
renderSection({ title: "Pinned output" });
expect(screen.getByText(/pinned output/i)).toBeInTheDocument();
});
it("pretty-prints the initial value into the textarea", () => {
renderSection({ value: { a: 1 } });
const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(ta.value).toContain(`"a": 1`);
});
it("renders empty textarea when value is null", () => {
renderSection({ value: null });
const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(ta.value).toBe("");
});
it("valid JSON triggers onChange with the parsed value", async () => {
const { props } = renderSection({ value: null });
const ta = screen.getByRole("textbox");
await userEvent.type(ta, `{{"a":2}`); // `{{` types a literal `{` in userEvent
const calls = (props.onChange as ReturnType<typeof vi.fn>).mock.calls;
// Last call should be the last valid parse. Depending on typing order,
// the final complete string `{"a":2}` should parse cleanly.
expect(calls.some(([v]) => JSON.stringify(v) === `{"a":2}`)).toBe(true);
});
it("invalid JSON shows inline error and does NOT call onChange", async () => {
const onChange = vi.fn();
renderSection({ value: null, onChange });
const ta = screen.getByRole("textbox");
await userEvent.type(ta, "{{ not json");
expect(screen.getByTestId("pin-parse-error")).toHaveTextContent(/invalid json/i);
expect(onChange).not.toHaveBeenCalled();
});
it("whitespace-only textarea calls onChange with null", async () => {
const { props } = renderSection({ value: { a: 1 } });
const ta = screen.getByRole("textbox");
await userEvent.clear(ta);
await userEvent.type(ta, " ");
const calls = (props.onChange as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.at(-1)![0]).toBeNull();
});
it("Clear button empties the textarea and calls onChange(null)", async () => {
const { props } = renderSection({ value: { a: 1 } });
await userEvent.click(screen.getByRole("button", { name: /^clear$/i }));
const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(ta.value).toBe("");
const calls = (props.onChange as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.at(-1)![0]).toBeNull();
});
it("Load from execution dropdown lists recent executions and fills the textarea", async () => {
server.use(
http.get("/api/workflows/:id/executions", () =>
HttpResponse.json([
{ id: "e1", workflowId: "wf1", trigger: "manual", status: "completed",
startedAt: "2025-01-01T00:00:00Z", endedAt: null, 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: { loaded: true } }]], outputs: [] },
],
} satisfies ExecutionDetail)),
);
const { props } = renderSection({ value: null });
// Wait for the dropdown to hydrate with the fetched executions.
await screen.findByRole("option", { name: /e1/i });
const select = screen.getByLabelText(/load from execution/i);
await userEvent.selectOptions(select, "e1");
await waitFor(() => {
const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(ta.value).toContain(`"loaded": true`);
});
const calls = (props.onChange as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.some(([v]) => JSON.stringify(v) === `[{"json":{"loaded":true}}]`)).toBe(true);
});
it("Load-from-execution for a trigger uses outputs[0] not inputs[0]", async () => {
server.use(
http.get("/api/workflows/:id/executions", () =>
HttpResponse.json([
{ id: "e1", workflowId: "wf1", trigger: "manual", status: "completed",
startedAt: "2025-01-01T00:00:00Z", endedAt: null, 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: "t1", status: "completed", startedAt: "", endedAt: "", error: null,
inputs: [], outputs: [[{ json: { fromOutput: true } }]] },
],
} satisfies ExecutionDetail)),
);
renderSection({ nodeId: "t1", side: "outputs" });
await screen.findByRole("option", { name: /e1/i });
const select = screen.getByLabelText(/load from execution/i);
await userEvent.selectOptions(select, "e1");
await waitFor(() => {
const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(ta.value).toContain(`"fromOutput": true`);
});
});
});

View File

@@ -0,0 +1,136 @@
import { useEffect, useState } from "react";
import { useExecution, useExecutions } from "@/api/hooks";
import type { NodeExecutionDto } from "@/api/types";
interface Props {
title: string; // "Pinned input" | "Pinned output"
workflowId: string;
nodeId: string;
side: "inputs" | "outputs"; // which side of the execution row to load from
value: unknown | null; // parent-owned parsed value
onChange: (v: unknown | null) => void;
}
export function PinnedDataSection({
title, workflowId, nodeId, side, value, onChange,
}: Props) {
const [text, setText] = useState<string>(() => stringifyOrEmpty(value));
const [error, setError] = useState<string | null>(null);
const [loadEid, setLoadEid] = useState<string>("");
// If the parent's value changes while we're not editing (e.g. after a
// load-from-execution or a Clear), keep the textarea in sync. We only
// re-sync when the current text is invalid (let parse-error stand) OR the
// current text parses to a different value than the parent's.
useEffect(() => {
const parsedCurrent = safeParse(text);
if (parsedCurrent === undefined) return; // parse-error state — don't overwrite
if (canonicalize(parsedCurrent) === canonicalize(value)) return;
setText(stringifyOrEmpty(value));
}, [value]); // deliberately not depending on `text`
function commit(next: string) {
setText(next);
const trimmed = next.trim();
if (trimmed === "") {
setError(null);
onChange(null);
return;
}
try {
const parsed = JSON.parse(trimmed);
setError(null);
onChange(parsed);
} catch (e) {
setError(`Invalid JSON: ${(e as Error).message}`);
// Do NOT call onChange — keep parent state at last valid parse.
}
}
function clear() {
setText("");
setError(null);
onChange(null);
}
const executions = useExecutions(workflowId);
const loadedExec = useExecution(loadEid || undefined);
// Once the selected execution's detail arrives, splice its inputs[0] /
// outputs[0] for our node into the textarea.
useEffect(() => {
if (!loadEid || !loadedExec.data) return;
const row = loadedExec.data.nodes.find((n: NodeExecutionDto) => n.nodeId === nodeId);
if (!row) return;
const source = side === "inputs" ? row.inputs : row.outputs;
const port0 = Array.isArray(source) ? source[0] : undefined;
if (port0 === undefined) return;
const pretty = JSON.stringify(port0, null, 2);
commit(pretty);
setLoadEid(""); // reset the dropdown to "latest"
}, [loadEid, loadedExec.data]);
return (
<div className="bg-slate-950/60 border border-slate-800 rounded p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs uppercase text-slate-500">{title}</span>
<div className="flex items-center gap-1">
<label className="text-xs text-slate-500" htmlFor="load-from-exec">
Load from execution:
</label>
<select
id="load-from-exec"
aria-label="Load from execution"
value={loadEid}
onChange={(e) => setLoadEid(e.target.value)}
className="bg-slate-950 border border-slate-800 rounded text-xs px-2 py-1"
>
<option value="">— latest —</option>
{(executions.data ?? []).slice(0, 10).map((r) => (
<option key={r.id} value={r.id}>
{r.id.slice(0, 8)} · {r.status} · {r.trigger}
</option>
))}
</select>
<button
type="button"
onClick={clear}
className="text-xs px-2 py-1 text-slate-400 hover:text-slate-200"
>
Clear
</button>
</div>
</div>
<textarea
aria-label={title}
value={text}
onChange={(e) => commit(e.target.value)}
placeholder="(none — this node runs on real upstream data)"
rows={10}
spellCheck={false}
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-xs font-mono leading-5 resize-none"
/>
{error && (
<div data-testid="pin-parse-error" className="text-xs text-amber-400">
⚠ {error}
</div>
)}
</div>
);
}
function stringifyOrEmpty(v: unknown | null): string {
if (v === null || v === undefined) return "";
return JSON.stringify(v, null, 2);
}
function safeParse(s: string): unknown | undefined {
const t = s.trim();
if (t === "") return null;
try { return JSON.parse(t); } catch { return undefined; }
}
// Very small structural fingerprint. Sufficient for parent-vs-text equality.
function canonicalize(v: unknown): string {
return JSON.stringify(v);
}