fix(editor): auto-save before Run so in-memory edits actually execute

The Run button in NodeEditorDialog and the Test-run button in the editor
toolbar both POST /api/workflows/{id}/executions, which reads the graph
from the DB. Any un-saved edit (Python script tweak, config change, a
freshly-dragged node) was silently ignored.

Both entry points now await the workflow save before starting the
execution. New Vitest pins the save-then-post ordering in the dialog.
This commit is contained in:
Claude
2026-08-31 08:49:09 +00:00
parent dc32b6def9
commit 9c164282a1
3 changed files with 36 additions and 3 deletions

View File

@@ -49,6 +49,7 @@ function renderDialog(overrides: Partial<ComponentProps<typeof NodeEditorDialog>
onPatch: vi.fn(), onPatch: vi.fn(),
onDelete: vi.fn(), onDelete: vi.fn(),
onClose: vi.fn(), onClose: vi.fn(),
onSave: vi.fn().mockResolvedValue(undefined),
...overrides, ...overrides,
}; };
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -76,6 +77,7 @@ function renderControlledDialog(overrides: Partial<ComponentProps<typeof NodeEdi
onPatch={(patch) => setN((prev) => ({ ...prev, data: { ...prev.data, ...patch } }))} onPatch={(patch) => setN((prev) => ({ ...prev, data: { ...prev.data, ...patch } }))}
onDelete={overrides.onDelete ?? (() => {})} onDelete={overrides.onDelete ?? (() => {})}
onClose={overrides.onClose ?? (() => {})} onClose={overrides.onClose ?? (() => {})}
onSave={overrides.onSave ?? (() => Promise.resolve())}
/> />
); );
} }
@@ -134,6 +136,28 @@ describe("NodeEditorDialog", () => {
expect(screen.getByRole("button", { name: /^run/i })).toBeDisabled(); expect(screen.getByRole("button", { name: /^run/i })).toBeDisabled();
}); });
it("Run button awaits onSave before POSTing the execution", async () => {
const events: string[] = [];
const onSave = vi.fn().mockImplementation(async () => {
events.push("save");
});
server.use(
http.post("/api/workflows/:id/executions", () => {
events.push("post");
return HttpResponse.json({ executionId: "e-new" }, { status: 202 });
}),
http.get("/api/executions/e-new", () =>
HttpResponse.json({
id: "e-new", workflowId: "wf1", trigger: "manual", status: "completed",
startedAt: "", endedAt: "", error: null, nodes: [],
} satisfies ExecutionDetail)),
);
renderDialog({ onSave });
await userEvent.click(screen.getByRole("button", { name: /^run/i }));
await waitFor(() => expect(events).toEqual(["save", "post"]));
expect(onSave).toHaveBeenCalledTimes(1);
});
it("Run button POSTs an execution and shows the fresh row on success", async () => { it("Run button POSTs an execution and shows the fresh row on success", async () => {
server.use( server.use(
http.post("/api/workflows/:id/executions", () => http.post("/api/workflows/:id/executions", () =>

View File

@@ -22,11 +22,14 @@ interface Props {
onPatch: (patch: Partial<VcNodeData>) => void; onPatch: (patch: Partial<VcNodeData>) => void;
onDelete: () => void; onDelete: () => void;
onClose: () => void; onClose: () => void;
// Persist the current in-memory graph. The dialog awaits this before starting a Run
// so unsaved edits (Python script tweaks, config changes) actually take effect.
onSave: () => Promise<void>;
} }
export function NodeEditorDialog({ export function NodeEditorDialog({
node, nodeTypes, connections, workflowId, workflowActive, node, nodeTypes, connections, workflowId, workflowActive,
triggerNodeIds, onPatch, onDelete, onClose, triggerNodeIds, onPatch, onDelete, onClose, onSave,
}: Props) { }: Props) {
const nt = nodeTypes.find((t) => t.id === node.data.typeId); const nt = nodeTypes.find((t) => t.id === node.data.typeId);
const focusedFieldRef = useRef<ExpressionFieldHandle | null>(null); const focusedFieldRef = useRef<ExpressionFieldHandle | null>(null);
@@ -91,9 +94,12 @@ export function NodeEditorDialog({
const invalidate = useInvalidate(); const invalidate = useInvalidate();
const qc = useQueryClient(); const qc = useQueryClient();
const runMutation = useApiMutation( const runMutation = useApiMutation(
() => { async () => {
const startFrom = triggerNodeIds[0]; const startFrom = triggerNodeIds[0];
if (!startFrom) throw new Error("no trigger"); if (!startFrom) throw new Error("no trigger");
// Persist in-memory graph edits first — the executor reads from the DB,
// so an un-saved script tweak would otherwise be ignored on Run.
await onSave();
return apiFetch<{ executionId: string }>( return apiFetch<{ executionId: string }>(
`/api/workflows/${workflowId}/executions`, `/api/workflows/${workflowId}/executions`,
{ method: "POST", body: { startFromNodeId: startFrom, triggerData: {} } }, { method: "POST", body: { startFromNodeId: startFrom, triggerData: {} } },

View File

@@ -103,9 +103,11 @@ export default function EditorPage() {
); );
const testRun = useApiMutation( const testRun = useApiMutation(
() => { async () => {
const startFrom = selectedId || triggerNodeIds[0]; const startFrom = selectedId || triggerNodeIds[0];
if (!startFrom) throw new Error("Add a trigger node or select a node to start from."); if (!startFrom) throw new Error("Add a trigger node or select a node to start from.");
// Persist in-memory graph edits first — the executor reads from the DB.
await save.mutateAsync();
return apiFetch<{ executionId: string }>( return apiFetch<{ executionId: string }>(
`/api/workflows/${id}/executions`, `/api/workflows/${id}/executions`,
{ method: "POST", body: { startFromNodeId: startFrom, triggerData: {} } }, { method: "POST", body: { startFromNodeId: startFrom, triggerData: {} } },
@@ -223,6 +225,7 @@ export default function EditorPage() {
onPatch={(patch) => patchNode(editingNode.id, patch)} onPatch={(patch) => patchNode(editingNode.id, patch)}
onDelete={() => deleteNode(editingNode.id)} onDelete={() => deleteNode(editingNode.id)}
onClose={() => setEditingId(null)} onClose={() => setEditingId(null)}
onSave={() => save.mutateAsync()}
/> />
)} )}
</div> </div>