U12 part 5: put real workflow adjacency on the wire — custom-workflow move menus were guessing (measured), and the VALID_TRANSITIONS shortcut is gone (#2525)
## U12 part 5 — the move menu was guessing; now it asks the graph **Stacks on #2521** (same file). Merge that first. The context menu had **no adjacency data at all**, so it did two wrong things at once: it approximated move targets from a column's **neighbours in declared order**, and — because that approximation is strictly weaker than the real graph — it kept a `VALID_TRANSITIONS` shortcut for any workflow whose column-id set matched the six built-ins. Measured, the approximation loses real operator moves: | current | workflow graph | neighbour approximation | |---|---|---| | `in-progress` | in-review, todo, triage, done | todo, in-review | | `todo` | in-progress, triage, archived | triage, in-progress | | `done` | todo, triage, archived | in-review, archived | So **every custom workflow has been offering a guess**: menu entries the store would reject, and legal moves it never offered. The built-ins were fine only because the shortcut bypassed the guess entirely. ### The fix `BoardWorkflowColumn` gains `moveTargets`, resolved by `resolveAllowedColumns` — *the same resolver `moveTaskInternal` validates against*. The menu now offers exactly what the store will accept, for any workflow. Threaded through all four metadata builders (Board, Lane, ListView, TaskDetailModal). Optional on the wire, deliberately: a client older than this field keeps the neighbour fallback rather than losing its move menu mid-upgrade. ### Why deleting the legacy shortcut is safe Not an assertion — a measurement, then a pin. `resolveAllowedColumns(BUILTIN_CODING_WORKFLOW_IR, c)` is **identical to `VALID_TRANSITIONS[c]` for all six columns, order included**: ``` triage ["todo","archived"] == VALID SAME todo ["in-progress","triage","archived"] == VALID SAME in-progress ["in-review","todo","triage","done"] == VALID SAME in-review ["done","in-progress","todo","triage"] == VALID SAME done ["todo","triage","archived"] == VALID SAME archived ["done"] == VALID SAME ``` `builtin-adjacency-matches-legacy-transitions.test.ts` pins it so the equivalence cannot drift silently — if the built-in workflow's edges change without `VALID_TRANSITIONS` following, default menus change shape and that test fails first. It compares **order** too, since the menu renders targets in the order it receives them, so a reorder is operator-visible. Default-workflow menus are therefore byte-identical. Custom ones stop guessing. ### What's left of the legacy vocabulary here `COLUMNS` is gone from `TaskContextMenu` — deleting the shortcut removed its last use. `VALID_TRANSITIONS` survives for exactly one thing: the **no-metadata load window**, documented at the site. I measured removing that in #2521 and it left Task Detail with no move options during load, which is a regression rather than a cleanup. It retires when the load window does. ### Revert-proof, two ways - Drop the `declaredTargets` branch → the custom-workflow case fails: the neighbour fallback returns `["backlog","building"]`, missing the legal `shipped` jump **and** offering `backlog`, which that graph forbids. That is exactly the defect class shipped to every custom workflow today. - A second case pins that an adjacency edge into a column the board cannot show is **dropped**, not rendered as a dead menu entry. ### Verification `pnpm test:gate` (309 + 10 + 71), `pnpm lint`, `pnpm verify:fast`, core + dashboard typechecks green. **No new test failures**: five suites report 31 failures with and without the change — an identical, pre-existing set, verified by diffing failing test *names* against a stashed clean tree, not by comparing counts. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Move menus for custom workflows now show only the destinations permitted by that workflow. * Task-specific workflow rules are applied consistently across boards, lists, lanes, and task details. * Invalid or unavailable destinations are excluded from move options. * Existing clients remain supported when workflow destination data is unavailable. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/u12-move-targets-on-wire.md
Normal file
7
.changeset/u12-move-targets-on-wire.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Move menus on custom workflows now offer exactly the moves that workflow allows.
|
||||
category: fix
|
||||
dev: The board-workflows payload gains a per-column `moveTargets` array from `resolveAllowedColumns` — the same resolver `moveTaskInternal` validates against. `getTaskMoveTransitions` reads it instead of approximating targets from neighbouring columns, and the `VALID_TRANSITIONS` default-column-set shortcut is deleted; `builtin-adjacency-matches-legacy-transitions.test.ts` pins the equivalence that made deleting it safe. Optional on the wire, so an older client keeps the neighbour fallback.
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8):
|
||||
Pins the equivalence that let the dashboard's `VALID_TRANSITIONS` shortcut be DELETED.
|
||||
|
||||
The move menu could not use workflow adjacency because none was on the wire, so it
|
||||
approximated targets from a column's neighbours in declared order and kept a legacy
|
||||
shortcut for workflows whose column-id set matched the six built-ins — because the
|
||||
approximation is strictly weaker (in-progress: 4 real targets vs 2 neighbours).
|
||||
|
||||
Adjacency is now on the board-workflows payload, and the shortcut is gone. That is
|
||||
only safe because `resolveAllowedColumns(BUILTIN_CODING_WORKFLOW_IR, c)` is identical
|
||||
to `VALID_TRANSITIONS[c]` — same members AND same order — for every built-in column.
|
||||
This test is what stops that equivalence drifting silently: if the built-in workflow's
|
||||
edges change without `VALID_TRANSITIONS` following, default-workflow move menus change
|
||||
shape and this fails first.
|
||||
|
||||
It intentionally compares ORDER too, not just membership: the menu renders targets in
|
||||
the order it receives them, so a reordering is an operator-visible change.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
|
||||
import { resolveAllowedColumns } from "../workflow-transitions.js";
|
||||
import { COLUMNS } from "../types/board.js";
|
||||
import { VALID_TRANSITIONS } from "../types/board-config.js";
|
||||
|
||||
describe("built-in workflow adjacency vs the legacy transition table", () => {
|
||||
it.each(COLUMNS)("column %s resolves the same targets, in the same order", (column) => {
|
||||
expect(resolveAllowedColumns(BUILTIN_CODING_WORKFLOW_IR, column)).toEqual([
|
||||
...VALID_TRANSITIONS[column],
|
||||
]);
|
||||
});
|
||||
|
||||
it("covers every legacy column, so a new one cannot slip past this pin", () => {
|
||||
expect(COLUMNS.length).toBe(Object.keys(VALID_TRANSITIONS).length);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,14 @@ export interface BoardWorkflowColumn {
|
||||
/** Optional author-defined explanatory copy from the workflow IR. */
|
||||
description?: string;
|
||||
flags: BoardWorkflowColumnFlags;
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8):
|
||||
Columns this one may move to, from the workflow's own graph adjacency
|
||||
(`resolveAllowedColumns`, the same resolver `moveTaskInternal` validates against).
|
||||
Optional so a client that predates the field keeps working; the move menu falls back
|
||||
to approximating targets from neighbouring columns when it is absent.
|
||||
*/
|
||||
moveTargets?: string[];
|
||||
}
|
||||
|
||||
export interface BoardWorkflowDefinition {
|
||||
|
||||
@@ -596,7 +596,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
for (const workflow of boardWorkflows?.workflows ?? []) {
|
||||
map.set(workflow.id, workflow.columns
|
||||
.filter((column) => !column.flags.hiddenFromBoard)
|
||||
.map((column) => ({ id: column.id, label: column.name, flags: column.flags })));
|
||||
.map((column) => ({ id: column.id, label: column.name, flags: column.flags, ...(column.moveTargets ? { moveTargets: column.moveTargets } : {}) })));
|
||||
}
|
||||
return map;
|
||||
}, [boardWorkflows]);
|
||||
|
||||
@@ -88,7 +88,7 @@ function LaneComponent(props: LaneProps) {
|
||||
const contextMenuColumns = useMemo(
|
||||
() => workflow.columns
|
||||
.filter((col) => !col.flags.hiddenFromBoard)
|
||||
.map((col) => ({ id: col.id, label: col.name, flags: col.flags })),
|
||||
.map((col) => ({ id: col.id, label: col.name, flags: col.flags, ...(col.moveTargets ? { moveTargets: col.moveTargets } : {}) })),
|
||||
[workflow.columns],
|
||||
);
|
||||
const createColumnId = useMemo(() => (
|
||||
|
||||
@@ -754,9 +754,86 @@ export function ListView({
|
||||
|
||||
const listContextMenuColumns = useMemo<readonly TaskContextMenuColumnMetadata[] | undefined>(() => {
|
||||
if (!workflowMode) return undefined;
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — PR #2525 review, greptile):
|
||||
NO `moveTargets` on the shared list. In the "All workflows" view `listColumns` is a
|
||||
UNION across workflows keyed by column id, so two workflows that reuse an id but
|
||||
declare different edges collapse into one entry — and every task would be handed
|
||||
the first workflow's adjacency. That offers moves the store rejects and hides legal
|
||||
ones. Adjacency is per-workflow and must be resolved per TASK, which
|
||||
`taskContextMenuColumnsByTaskId` below does; this shared list keeps labels and
|
||||
flags only, where the union is harmless.
|
||||
*/
|
||||
return listColumns.map((column) => ({ id: column.id, label: column.name, flags: column.flags }));
|
||||
}, [listColumns, workflowMode]);
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — PR #2525 review, greptile):
|
||||
Per-task column metadata, mirroring Board's `taskContextMenuColumnsByTaskId`. Each
|
||||
task gets ITS OWN workflow's columns — including that workflow's `moveTargets` — so
|
||||
the aggregate view cannot serve one workflow's adjacency to another's card. Falls
|
||||
back to the shared union when the task's workflow is unresolvable, which yields the
|
||||
previous (neighbour-approximated) behaviour rather than a wrong answer.
|
||||
*/
|
||||
const taskContextMenuColumnsByTaskId = useMemo(() => {
|
||||
const map = new Map<string, readonly TaskContextMenuColumnMetadata[]>();
|
||||
if (!workflowMode || !boardWorkflows) return map;
|
||||
const byWorkflowId = new Map<string, readonly TaskContextMenuColumnMetadata[]>();
|
||||
for (const workflow of boardWorkflows.workflows) {
|
||||
byWorkflowId.set(
|
||||
workflow.id,
|
||||
workflow.columns
|
||||
.filter((column) => column.flags?.hiddenFromBoard !== true)
|
||||
.map((column) => ({
|
||||
id: column.id,
|
||||
label: column.name,
|
||||
flags: column.flags,
|
||||
...(column.moveTargets ? { moveTargets: column.moveTargets } : {}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
for (const task of tasks) {
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (PR #2528 review — greptile):
|
||||
VALIDATE the mapped id before trusting it. `taskWorkflowIds` can carry a STALE or
|
||||
unknown entry — a workflow deleted since the payload was built, or an id the
|
||||
client has not seen — and a bare `?? defaultWorkflowId` only covers the MISSING
|
||||
case, not the invalid one. An unknown id then resolves to no columns, the task
|
||||
silently drops back to the adjacency-free shared union, and the menu is wrong in
|
||||
exactly the way this whole change exists to prevent.
|
||||
|
||||
Mirrors Board's `getEffectiveTaskWorkflowId`, which already validates against the
|
||||
known-workflow set for the same reason.
|
||||
*/
|
||||
const assigned = boardWorkflows.taskWorkflowIds[task.id];
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (PR #2525 review — greptile):
|
||||
An UNMAPPED task is unknown, not default. `buildBoardWorkflowsPayload` writes an
|
||||
entry for every task it is given (null selection included), so a MISSING entry
|
||||
does not mean "no selection" — it means this task is NEWER than the payload,
|
||||
which happens routinely because the SSE task list updates before board-workflows
|
||||
does. Assuming the default workflow there would assert the default's adjacency on
|
||||
a card that may belong to another workflow entirely — precisely the wrong answer,
|
||||
confidently stated, for the cards most likely to be affected (freshly created
|
||||
ones, which is exactly when a workflow was chosen).
|
||||
|
||||
Leave such a task without per-workflow metadata: it falls back to the shared
|
||||
union and the neighbour approximation, which is the pre-existing behaviour and an
|
||||
admitted guess rather than a false claim. Board additionally forces one
|
||||
board-workflows refetch when it sees unmapped rendered tasks (FN-7591); porting
|
||||
that self-heal to List is a real improvement and its own change.
|
||||
|
||||
A PRESENT but unknown id (stale/deleted workflow) still falls back to the default
|
||||
— there the entry is a real answer that has simply gone out of date.
|
||||
*/
|
||||
if (assigned === undefined) continue;
|
||||
const workflowId = byWorkflowId.has(assigned) ? assigned : boardWorkflows.defaultWorkflowId;
|
||||
const columns = workflowId ? byWorkflowId.get(workflowId) : undefined;
|
||||
if (columns) map.set(task.id, columns);
|
||||
}
|
||||
return map;
|
||||
}, [boardWorkflows, tasks, workflowMode]);
|
||||
|
||||
const getTaskPlanningWorkflowId = useCallback((task: Task): string | null => {
|
||||
const taskWorkflowId = (task as Task & { workflowId?: string | null }).workflowId;
|
||||
if (taskWorkflowId) return taskWorkflowId;
|
||||
@@ -1888,7 +1965,7 @@ export function ListView({
|
||||
t,
|
||||
columnLabel: getListColumnLabel,
|
||||
currentColumnFlags: columnFlagsById.get(task.column),
|
||||
workflowMoveColumns: listContextMenuColumns,
|
||||
workflowMoveColumns: taskContextMenuColumnsByTaskId.get(task.id) ?? listContextMenuColumns,
|
||||
canRetryTask,
|
||||
hasDuplicateHandler: Boolean(onDuplicateTask),
|
||||
hasRetryHandler: Boolean(onRetryTask),
|
||||
@@ -2015,7 +2092,7 @@ export function ListView({
|
||||
actions.push({ id: model.reviewAction.id, label: model.reviewAction.label, disabled: model.reviewAction.disabled, onSelect: model.reviewAction.onSelect });
|
||||
}
|
||||
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
|
||||
}, [addToast, autoMerge, columnFlagsById, confirm, getListColumnLabel, getTaskPlanningWorkflowId, handleListContextCheckPrStatus, handleListContextEnableGithubTracking, handleListContextMove, handleListTaskArchive, handleListTaskDelete, handleListTaskRevert, isMobile, lastFetchTimeMs, listContextMenuColumns, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPlanningMode, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, onRevertTask, onTasksUpdated, projectId, t, useSinglePaneList]);
|
||||
}, [addToast, autoMerge, columnFlagsById, confirm, getListColumnLabel, getTaskPlanningWorkflowId, handleListContextCheckPrStatus, handleListContextEnableGithubTracking, handleListContextMove, handleListTaskArchive, handleListTaskDelete, handleListTaskRevert, isMobile, lastFetchTimeMs, listContextMenuColumns, taskContextMenuColumnsByTaskId, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPlanningMode, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, onRevertTask, onTasksUpdated, projectId, t, useSinglePaneList]);
|
||||
|
||||
const contextMenuActions = useMemo(
|
||||
() => (contextMenuState ? buildListContextMenuActions(contextMenuState.task) : []),
|
||||
|
||||
@@ -3,7 +3,11 @@ import type { KeyboardEvent, PointerEvent as ReactPointerEvent, MouseEvent as Re
|
||||
import { Fragment, useCallback, useEffect, useRef } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { ColumnId, Task, TaskDetail, WorkflowStepResult } from "@fusion/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, isColumn } from "@fusion/core";
|
||||
import { VALID_TRANSITIONS, isColumn } from "@fusion/core";
|
||||
// `COLUMNS` is gone from this file: deleting the default-column-set shortcut removed
|
||||
// the last use. `VALID_TRANSITIONS` survives ONLY for the no-metadata load window (see
|
||||
// the note at `moveTransitions`); every workflow-resolved path now reads the payload's
|
||||
// own `moveTargets` adjacency.
|
||||
|
||||
/*
|
||||
FNXC:ReviewLaneBypass 2026-07-09-00:00:
|
||||
@@ -55,6 +59,9 @@ export interface TaskContextMenuColumnMetadata {
|
||||
id: ColumnId;
|
||||
label: string;
|
||||
flags?: TaskContextMenuColumnFlags;
|
||||
/** Columns this one may move to, from the workflow's own graph adjacency. Optional:
|
||||
* a payload predating the field falls back to the neighbour approximation. */
|
||||
moveTargets?: readonly string[];
|
||||
}
|
||||
|
||||
export interface TaskReviewActionDescriptor {
|
||||
@@ -143,11 +150,6 @@ export function isPreExecutionHoldColumn(column: string, flags?: TaskContextMenu
|
||||
return column === "triage" || flags?.intake === true || flags?.hold === true;
|
||||
}
|
||||
|
||||
function isDefaultWorkflowColumnSet(columns: readonly TaskContextMenuColumnMetadata[]): boolean {
|
||||
if (columns.length !== COLUMNS.length) return false;
|
||||
const ids = new Set(columns.map((column) => column.id));
|
||||
return COLUMNS.every((column) => ids.has(column));
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskContextMenu 2026-06-30-12:42:
|
||||
@@ -159,33 +161,30 @@ Manual pull-request review has two separate operator intents: Start PR Review op
|
||||
function getWorkflowMoveTargets(task: Task | TaskDetail, columns: readonly TaskContextMenuColumnMetadata[]): ColumnId[] {
|
||||
const visibleColumns = columns.filter((column) => column.flags?.hiddenFromBoard !== true);
|
||||
/*
|
||||
FNXC:TaskContextMenu 2026-07-29-00:00 (U12 — R8, KNOWN REMAINING GAP):
|
||||
This `VALID_TRANSITIONS` shortcut is the LAST legacy-vocabulary read in this file and
|
||||
it is deliberately KEPT, because removing it today would silently SHRINK the move
|
||||
menu for every default-workflow project rather than fix anything.
|
||||
FNXC:TaskContextMenu 2026-07-29-00:00 (U12 — R8):
|
||||
REAL ADJACENCY, when the payload carries it. `moveTargets` comes from
|
||||
`resolveAllowedColumns` — the same resolver `moveTaskInternal` validates against — so
|
||||
the menu offers exactly what the store will accept, for ANY workflow.
|
||||
|
||||
The reason is a missing wire field, not a missing idea. `TaskContextMenuColumnMetadata`
|
||||
carries id/label/flags but NO adjacency, so the workflow branch below can only guess
|
||||
targets from a column's neighbours in the declared order — [previous, next]. Measured
|
||||
against the real graph that is a strict loss:
|
||||
This replaces the `VALID_TRANSITIONS` shortcut that used to run whenever a workflow's
|
||||
column-id set matched the six built-ins. That shortcut existed because the fallback
|
||||
below approximates targets from a column's NEIGHBOURS in declared order, which is
|
||||
strictly weaker than the graph (in-progress: 4 real targets vs 2 neighbours), so
|
||||
deleting it without adjacency would have SHRUNK every default-workflow move menu.
|
||||
|
||||
in-progress VALID_TRANSITIONS: in-review, todo, triage, done (4)
|
||||
neighbour-derived: todo, in-review (2)
|
||||
todo VALID_TRANSITIONS: in-progress, triage, archived (3)
|
||||
neighbour-derived: triage, in-progress (2)
|
||||
done VALID_TRANSITIONS: todo, triage, archived (3)
|
||||
neighbour-derived: in-review, archived (2)
|
||||
With adjacency on the wire the shortcut is not merely removable, it is redundant:
|
||||
`resolveAllowedColumns(BUILTIN_CODING_WORKFLOW_IR, c)` is byte-identical to
|
||||
`VALID_TRANSITIONS[c]` for all six columns, ORDER included — measured, and pinned by
|
||||
`@fusion/core`'s `builtin-adjacency-matches-legacy-transitions` test so the
|
||||
equivalence cannot drift silently. Custom workflows stop being guessed at.
|
||||
|
||||
So "delete the legacy read" here is not a cleanup — it is a UI regression that drops
|
||||
real operator moves (archive from Todo, straight-to-Done from In progress). The
|
||||
correct fix is to put each column's allowed targets on the board-workflows payload
|
||||
and read THOSE, which changes the server, the wire shape and this file, and is its own
|
||||
slice. Note the guard is keyed on the COLUMN ID SET, so a workflow that merely renames
|
||||
the six built-in columns still takes this path and still gets correct targets; only a
|
||||
workflow that reorders or replaces them falls through to the weaker neighbour logic.
|
||||
Targets are filtered to columns this board can show, so an adjacency edge into a
|
||||
hidden column never becomes a dead menu entry.
|
||||
*/
|
||||
if (isDefaultWorkflowColumnSet(visibleColumns) && isColumn(task.column)) {
|
||||
return task.column === "in-review" ? ["todo", "in-progress"] : [...VALID_TRANSITIONS[task.column]];
|
||||
const declaredTargets = columns.find((column) => column.id === task.column)?.moveTargets;
|
||||
if (declaredTargets) {
|
||||
const visibleIds = new Set(visibleColumns.map((column) => column.id));
|
||||
return declaredTargets.filter((target) => visibleIds.has(target)) as ColumnId[];
|
||||
}
|
||||
|
||||
const currentIndex = visibleColumns.findIndex((column) => column.id === task.column);
|
||||
|
||||
@@ -528,7 +528,7 @@ function resolveTaskWorkflowMetadata(payload: BoardWorkflowsPayload, task: Pick<
|
||||
|
||||
const moveColumns = workflow.columns
|
||||
.filter((column) => column.flags.hiddenFromBoard !== true)
|
||||
.map((column) => ({ id: column.id as ColumnId, label: column.name, flags: column.flags }));
|
||||
.map((column) => ({ id: column.id as ColumnId, label: column.name, flags: column.flags, ...(column.moveTargets ? { moveTargets: column.moveTargets } : {}) }));
|
||||
const currentColumnFlags = moveColumns.find((column) => column.id === task.column)?.flags;
|
||||
return { id: workflow.id, name, icon: workflow.icon, fields: workflow.fields ?? null, moveColumns, currentColumnFlags };
|
||||
}
|
||||
|
||||
@@ -504,6 +504,60 @@ describe("U10 — surfaces render workflow-resolved columns", () => {
|
||||
expect(building?.label).toBe("Back to Building");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8):
|
||||
When the payload carries `moveTargets`, the menu uses the workflow's REAL graph
|
||||
adjacency instead of approximating from neighbouring columns. This fixture makes
|
||||
the two disagree on purpose: `staging` sits between `backlog` and `building`, so
|
||||
the neighbour approximation yields exactly those two — while the declared
|
||||
adjacency also allows a jump to `shipped` and forbids going back to `backlog`.
|
||||
|
||||
REVERT CHECK: drop the `declaredTargets` branch and this fails — the neighbour
|
||||
fallback returns ["backlog","building"], missing the legal `shipped` jump and
|
||||
offering `backlog`, which the workflow's graph does not allow. That is precisely
|
||||
the class of defect the old neighbour approximation shipped for every custom
|
||||
workflow: menu entries the store would reject, and legal moves it never offered.
|
||||
*/
|
||||
it("uses the workflow's declared adjacency, not neighbouring columns", () => {
|
||||
const withAdjacency: TaskContextMenuColumnMetadata[] = renamedMoveColumns.map((column) =>
|
||||
column.id === "staging"
|
||||
? { ...column, moveTargets: ["building", "shipped"] }
|
||||
: column,
|
||||
);
|
||||
const transitions = getTaskMoveTransitions(
|
||||
mkTask({ id: "FN-16", column: "staging" as Task["column"] }),
|
||||
t,
|
||||
columnLabel,
|
||||
withAdjacency,
|
||||
);
|
||||
expect(transitions.map((transition) => transition.column)).toEqual(["building", "shipped"]);
|
||||
});
|
||||
|
||||
it("drops an adjacency edge into a column this board cannot show", () => {
|
||||
/*
|
||||
Two distinct exclusions, because they fail differently (PR #2525 review —
|
||||
CodeRabbit): `hidden` is a DECLARED column carrying `hiddenFromBoard`, which only
|
||||
the visibility filter removes, and `nowhere` is an id the workflow does not
|
||||
declare at all. Testing only the unknown id would let the hidden-column filtering
|
||||
be deleted with the case still green.
|
||||
*/
|
||||
const withHidden: TaskContextMenuColumnMetadata[] = [
|
||||
...renamedMoveColumns.map((column) =>
|
||||
column.id === "staging"
|
||||
? { ...column, moveTargets: ["building", "hidden", "nowhere"] }
|
||||
: column,
|
||||
),
|
||||
{ id: "hidden" as ColumnId, label: "Hidden", flags: { hiddenFromBoard: true } },
|
||||
];
|
||||
const transitions = getTaskMoveTransitions(
|
||||
mkTask({ id: "FN-17", column: "staging" as Task["column"] }),
|
||||
t,
|
||||
columnLabel,
|
||||
withHidden,
|
||||
);
|
||||
expect(transitions.map((transition) => transition.column)).toEqual(["building"]);
|
||||
});
|
||||
|
||||
it("never offers a column the workflow does not declare", () => {
|
||||
const transitions = getTaskMoveTransitions(
|
||||
mkTask({ id: "FN-13", column: "in-review" }),
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
getBuiltinWorkflow,
|
||||
isBuiltinWorkflowId,
|
||||
parseWorkflowIr,
|
||||
resolveAllowedColumns,
|
||||
resolveColumnFlags,
|
||||
resolveWorkflowIrById,
|
||||
type Settings,
|
||||
@@ -48,6 +49,22 @@ export interface BoardWorkflowColumn {
|
||||
/** Optional author-defined explanatory copy; omitted keeps client lifecycle fallback. */
|
||||
description?: string;
|
||||
flags: TraitFlags;
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8):
|
||||
The columns this one may move to, resolved from the workflow's OWN graph adjacency
|
||||
(`resolveAllowedColumns`) — the same function `moveTaskInternal` validates against,
|
||||
so the menu offers exactly what the store will accept.
|
||||
|
||||
This field exists to retire the client's two remaining legacy-vocabulary reads. The
|
||||
context menu previously had no adjacency at all, so it approximated targets by a
|
||||
column's NEIGHBOURS in declared order and kept a `VALID_TRANSITIONS` shortcut for
|
||||
workflows whose column-id set matched the six built-ins — because the neighbour
|
||||
approximation is strictly weaker (in-progress: 4 real targets vs 2 neighbours).
|
||||
|
||||
Optional on the wire so a client older than this field keeps its previous behaviour
|
||||
rather than losing its move menu.
|
||||
*/
|
||||
moveTargets?: string[];
|
||||
}
|
||||
|
||||
/** A workflow definition in use by visible cards. */
|
||||
@@ -124,6 +141,7 @@ function describeColumns(ir: WorkflowIr, canonicalizeLifecycle = false): BoardWo
|
||||
name: displayColumnName(col.id, col.name, canonicalizeLifecycle),
|
||||
...(col.description ? { description: col.description } : {}),
|
||||
flags: resolveColumnFlags(col),
|
||||
moveTargets: resolveAllowedColumns(ir, col.id),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user