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