FN-6519: align workflow trait validation details

Align client workflow column validation with server trait conflict reporting.

- Add a helper that maps conflicting composed traits back to their catalog trait IDs.
- Populate client validation traitIds for complete/intake/archive/WIP conflicts.
- Cover client/server traitId parity for save-blocking workflow validation errors.
- Add a patch changeset for the published Fusion package.

Files changed:
 .../FN-6519-custom-workflow-trait-validator.md      |  5 +++++
 .../__tests__/workflow-flow-mapping.test.ts         | 19 ++++++++++++++++++-
 .../app/components/workflow-flow-mapping.ts         | 21 ++++++++++++++++++---
 3 files changed, 41 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-6519
Fusion-Task-Lineage: c6f3197f-5474-482b-a63b-9d20d3af3aa9
This commit is contained in:
gsxdsm
2026-06-16 22:59:08 -07:00
parent cc5c5eb7bd
commit c8788d85c5
3 changed files with 41 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Align the workflow editor's client-side column trait validation details with the server validator so conflicting trait compositions identify the same source traits before save.

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { WorkflowDefinition, WorkflowIrNodeKind } from "@fusion/core";
import { parseWorkflowIr } from "@fusion/core";
import { parseWorkflowIr, validateColumnTraits } from "@fusion/core";
import type { Node as FlowNode } from "@xyflow/react";
import {
irToFlow,
@@ -352,6 +352,23 @@ describe("workflow-flow-mapping validation helpers", () => {
expect(v?.columnId).toBeNull();
});
it("mirrors server trait ids for save-blocking composition conflicts", () => {
const columns = [
{ id: "complete-wip", name: "Complete WIP", traits: [{ trait: "complete" }, { trait: "wip" }] },
{ id: "two-wip", name: "Two WIP", traits: [{ trait: "wip" }, { trait: "wip" }] },
{ id: "done", name: "Done", traits: [{ trait: "complete" }, { trait: "intake" }] },
{ id: "archive", name: "Archive", traits: [{ trait: "archived" }, { trait: "wip" }] },
];
const clientViolations = validateColumnsClient(columns, CATALOG);
const serverViolations = validateColumnTraits(columns);
for (const code of ["complete-with-wip", "two-capacity-traits", "complete-with-intake", "archived-with-wip"] as const) {
expect(clientViolations.find((v) => v.code === code)?.traitIds.sort()).toEqual(
serverViolations.find((v) => v.code === code)?.traitIds.sort(),
);
}
});
it("reports unplaced step nodes (not start/end, not bands)", () => {
const columns = columnsOf(
v2Def({

View File

@@ -790,6 +790,21 @@ function mergedFlags(
return { flags, capacityTraitIds, unknown };
}
/*
FNXC:CustomWorkflows 2026-06-16-22:30:
The workflow editor's client-side trait validator mirrors the server validator, including traitIds used to identify the exact composed traits behind blocking save errors.
*/
function traitIdsWithFlags(
traits: WorkflowIrColumn["traits"],
catalog: Map<string, TraitCatalogEntry>,
names: Array<keyof CatalogFlags>,
): string[] {
return traits
.map((ct) => catalog.get(ct.trait))
.filter((def): def is TraitCatalogEntry => !!def && names.some((name) => !!def.flags[name]))
.map((def) => def.id);
}
/** Client mirror of core's validateColumnTraits, driven by the trait catalog. */
export function validateColumnsClient(
columns: WorkflowIrColumn[],
@@ -815,7 +830,7 @@ export function validateColumnsClient(
code: "complete-with-wip",
severity: "error",
columnId: col.id,
traitIds: capacityTraitIds,
traitIds: traitIdsWithFlags(col.traits, byId, ["complete", "countsTowardWip"]),
message: `Column '${col.name || col.id}' is both a completion column and counts toward WIP`,
});
}
@@ -833,7 +848,7 @@ export function validateColumnsClient(
code: "complete-with-intake",
severity: "error",
columnId: col.id,
traitIds: [],
traitIds: traitIdsWithFlags(col.traits, byId, ["complete", "intake"]),
message: `Column '${col.name || col.id}' is both a completion column and an intake column`,
});
}
@@ -842,7 +857,7 @@ export function validateColumnsClient(
code: "archived-with-wip",
severity: "error",
columnId: col.id,
traitIds: [],
traitIds: traitIdsWithFlags(col.traits, byId, ["archived", "countsTowardWip"]),
message: `Column '${col.name || col.id}' is archived but counts toward WIP`,
});
}