feat(dashboard): preserve optionalSteps through flowToIr round-trip

Thread an optionalSteps param through flowToIr (counts as a v2 signal,
re-attached like fields/settings, omitted entirely when empty for byte
identity) and add an optionalStepsOf reader mirroring fieldsOf/settingsOf.
Without this, saving a workflow through the node editor silently dropped
its optional-step declaration.
This commit is contained in:
gsxdsm
2026-06-21 00:05:13 -07:00
parent d4e91d4597
commit 4c7bfcf175
2 changed files with 106 additions and 3 deletions

View File

@@ -9,6 +9,7 @@ import {
fragmentSeamConflicts,
copyIrWithFreshIds,
columnsOf,
optionalStepsOf,
columnForY,
bandTop,
columnsToBandNodes,
@@ -1503,3 +1504,84 @@ describe("copyIrWithFreshIds", () => {
expect(t1NewId).toEqual({ x: 10, y: 20 });
});
});
describe("optionalSteps round-trip (U2)", () => {
const v2WithOptional = (optionalSteps?: { templateId: string; defaultOn?: boolean }[]) =>
makeDef(
parseWorkflowIr({
version: "v2",
name: "wf-opt",
columns: [
{ id: "triage", name: "Triage", traits: [] },
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "triage" },
{ id: "end", kind: "end", column: "done" },
],
edges: [{ from: "start", to: "end" }],
...(optionalSteps ? { optionalSteps } : {}),
}),
);
it("optionalStepsOf reads declarations from a v2 IR and returns a copy", () => {
const def = v2WithOptional([{ templateId: "browser-verification", defaultOn: true }]);
const read = optionalStepsOf(def);
expect(read).toEqual([{ templateId: "browser-verification", defaultOn: true }]);
// mutating the result does not mutate the source IR
read[0].defaultOn = false;
expect(optionalStepsOf(def)).toEqual([{ templateId: "browser-verification", defaultOn: true }]);
});
it("optionalStepsOf returns [] for v1 and for v2 without optionalSteps", () => {
const v1 = makeDef({
version: "v1",
name: "legacy",
nodes: [
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
],
edges: [{ from: "start", to: "end" }],
});
expect(optionalStepsOf(v1)).toEqual([]);
expect(optionalStepsOf(v2WithOptional())).toEqual([]);
});
it("flowToIr preserves optionalSteps across a full irToFlow round-trip", () => {
const def = v2WithOptional([{ templateId: "browser-verification", defaultOn: true }]);
const { nodes, edges } = irToFlow(def);
const { ir: out } = flowToIr("wf-opt", nodes, edges, columnsOf(def), [], [], optionalStepsOf(def));
expect((out as { optionalSteps?: unknown }).optionalSteps).toEqual([
{ templateId: "browser-verification", defaultOn: true },
]);
});
it("serializes as v2 when optionalSteps present but no custom columns/fields/settings", () => {
const { ir: out } = flowToIr(
"opt-only",
[
{ id: "start", type: "workflowNode", position: { x: 0, y: 0 }, data: { kind: "start" } },
{ id: "end", type: "workflowNode", position: { x: 0, y: 200 }, data: { kind: "end" } },
] as unknown as FlowNode<WorkflowFlowNodeData>[],
[{ id: "e1", source: "start", target: "end" }],
[],
[],
[],
[{ templateId: "browser-verification" }],
);
expect(out.version).toBe("v2");
expect((out as { optionalSteps?: unknown }).optionalSteps).toEqual([
{ templateId: "browser-verification" },
]);
});
it("omits the optionalSteps key entirely when empty (R6 byte-identity)", () => {
const def = v2WithOptional();
const { nodes, edges } = irToFlow(def);
const { ir: out } = flowToIr("wf-opt", nodes, edges, columnsOf(def), [], [], []);
expect("optionalSteps" in out).toBe(false);
// and with the arg omitted entirely
const { ir: out2 } = flowToIr("wf-opt", nodes, edges, columnsOf(def));
expect("optionalSteps" in out2).toBe(false);
});
});

View File

@@ -9,6 +9,7 @@ import type {
WorkflowDefinition,
WorkflowFieldDefinition,
WorkflowSettingDefinition,
WorkflowOptionalStep,
} from "@fusion/core";
import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
@@ -434,6 +435,7 @@ export function flowToIr(
columns?: WorkflowIrColumn[],
fields?: WorkflowFieldDefinition[],
settings?: WorkflowSettingDefinition[],
optionalSteps?: WorkflowOptionalStep[],
): { ir: WorkflowIr; layout: Record<string, { x: number; y: number }> } {
const realNodes = nodes.filter((n) => !isColumnBandNode(n.id));
// Partition by parentId: foreach group children reassemble into that group's
@@ -453,9 +455,12 @@ export function flowToIr(
);
const hasFields = Array.isArray(fields) && fields.length > 0;
const hasSettings = Array.isArray(settings) && settings.length > 0;
// Fields and settings are v2-only declarations: a workflow with either but no
// custom columns still serializes as v2 (with the synthesized default columns).
const v2 = (Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings;
const hasOptionalSteps = Array.isArray(optionalSteps) && optionalSteps.length > 0;
// Fields, settings, and optional steps are v2-only declarations: a workflow with
// any of them but no custom columns still serializes as v2 (with the synthesized
// default columns). Empty/absent → not a v2 signal (R6 byte-identity).
const v2 =
(Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings || hasOptionalSteps;
const layout: Record<string, { x: number; y: number }> = {};
/** Project one flow node (top-level or template child) into an IR node. */
@@ -556,6 +561,12 @@ export function flowToIr(
render: s.render ? { ...s.render } : undefined,
}));
}
if (hasOptionalSteps) {
// Optional-step DECLARATIONS round-trip through the editor opaquely (they are
// not graph nodes; the resolver + server validator are the source of truth).
// Omitted entirely when empty so legacy graphs stay byte-identical (R6).
(ir as { optionalSteps?: unknown }).optionalSteps = optionalSteps!.map((o) => ({ ...o }));
}
return { ir, layout };
}
@@ -967,6 +978,16 @@ export function settingsOf(def: WorkflowDefinition): WorkflowSettingDefinition[]
}));
}
/** Extract the editor's working optional-step declaration list from a definition.
* v2 with `optionalSteps` → a shallow copy; v1 or none → empty. Display metadata
* (name/icon/phase) is NOT carried here — it is resolved from the step-template
* catalog at render time so the resolver stays the single source of truth. */
export function optionalStepsOf(def: WorkflowDefinition): WorkflowOptionalStep[] {
const ir = def.ir as { optionalSteps?: WorkflowOptionalStep[] };
if (!isV2(def.ir) || !Array.isArray(ir.optionalSteps)) return [];
return ir.optionalSteps.map((o) => ({ ...o }));
}
/** Seed graph for a brand-new workflow: start → end with room to insert steps. */
export function emptyWorkflowIr(name: string): WorkflowIr {
return {