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