feat(editor): PinnedDataSection — JSON editor + load-from-execution dropdown
This commit is contained in:
151
frontend/src/editor/PinnedDataSection.test.tsx
Normal file
151
frontend/src/editor/PinnedDataSection.test.tsx
Normal 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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
136
frontend/src/editor/PinnedDataSection.tsx
Normal file
136
frontend/src/editor/PinnedDataSection.tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user