Phase D / **U10** of the workflow-owned-lifecycle program (**R8**). This
unit **blocks U11** (merge Todo into Planning) — the board must render
IR-resolved columns before the column shape can change.
## What was wrong
Six dashboard surfaces answered a column question from the legacy
`COLUMNS` / `VALID_TRANSITIONS` vocabulary rather than the card's own
workflow IR. Each is a defect today, and each is a way U11 would ship
visibly broken.
| # | Surface | Defect |
|---|---|---|
| 1 | Board — All workflows | Appended **every** legacy column id to the
lane union with synthesised flags → a phantom lane for a column no
workflow declares, labelled with the raw id, ordered by the enum index
with an alphabetical tie-break that scrambled a custom workflow's
declared order |
| 2 | ListView | `if (groups[column])` **silently dropped** a row whose
stored column the workflow no longer declares — no lane, no row, no
error |
| 3 | Move menu | A card stranded in an undeclared column got an **empty
move list** — the one surface that could rescue it offered nothing |
| 4 | Task Detail | Header badge rendered the raw stored id;
title/description editing gated on the literal `{triage, todo}` — a
renamed planning lane lost Edit with nothing on screen to explain it |
| 5 | `board-workflows` | The built-in lifecycle label map was an
**override**, not a fallback, so it replaced a name a built-in
deliberately chose |
| 6 | `POST /tasks/:id/move` | The open-PR backward guard used
`COLUMNS.indexOf(...)` → **-1 on any renamed board**, and the guard
treats a negative index as "allow" |
**#6 is the one worth reading twice.** The guard did not start rejecting
the wrong things — it stopped existing. On a renamed board an operator
could drag a card backward out of review with an open GitHub PR,
orphaning it, and nothing failed. This is precisely the "a converted
guard silently stops firing" row in the plan's risk table, reached
through a rename rather than a conversion.
The re-engage copy of that same guard is **deliberately left on the
legacy enum**, with a comment saying why: it is gated on literal
`in-review` / `in-progress` end to end, so converting only its indices
would make it *weaker* (a workflow declaring `in-review` but not
`in-progress` would score -1 and disable it). U5 owns that lane.
## Evidence
**Every fix has a test that fails when the fix is reverted.** With the
six production files stashed and the tests kept, **10 of the 28 tests
fail**:
- Board aggregate: phantom lane present (2)
- ListView: stranded card dropped, desktop **and** mobile (2)
- Move menu: empty move list for a stranded card (1)
- Task Detail: badge shows `staging`, Edit missing in a renamed intake
**and** hold lane (3)
- `board-workflows`: `builtin:lead-generation`'s `triage` renders as
"Planning" (1)
- Move route: backward move between renamed columns **allowed** with an
open PR (1)
The other 18 are regression pins on behaviour that must not change
(default-workflow lane order and labels, legacy `in-review →
in-progress` block, legacy editable columns, forward moves, terminal
PRs).
**Measured, not estimated.** The label-map clobber was quantified
against the built-in IRs actually in tree: **4 column names replaced — 3
case-only variants ("In progress" → "In Progress"), 1 genuine semantic
rename.** Only the rename is a user-visible defect; the fix preserves
the case normalisation rather than churning the default board.
## Surface enumeration (AGENTS.md)
Desktop **and** mobile — the breakpoint is `(max-width: 768px),
(max-height: 480px)`, so landscape phones exceed 768 wide and match on
height. Column states: empty, populated, duplicate id across two
workflows, and a column no workflow declares. Views: single-workflow
lane, All-workflows aggregate, list, move menu, task detail, move route.
## Regression check
Full dashboard suite, both sides of the change:
| | Test Files | Tests |
|---|---|---|
| Before | 41 failed / 1068 | **296 failed** / 21195 |
| After | 42 failed / 1071 | **297 failed** / 21223 |
`+28` total is exactly the tests this change adds. The single failure
delta is `register-model-routes-kimi-k3-supplemental`, which **fails
identically on this branch's base when run in isolation** — shard-order
dependent, unrelated to columns. **Zero regressions attributable to
U10.** The ~296 pre-existing dashboard failures are inherited from main
and are flagged to the coordinator, not touched here.
`pnpm test:gate`, `pnpm lint`, both dashboard typechecks
(`tsconfig.json` and `tsconfig.app.json`), `pnpm smoke:boot`, and `pnpm
check:changesets` are green.
## Not in this unit
- `Board`'s legacy single-lane `COLUMNS.map` fallback still exists.
`MainContent` passes `workflowColumnsEnabled` unconditionally, so it is
unreachable in-app, but proving that is a deletion argument and this is
not a deletion unit — flagging rather than removing.
- `ListView`'s `LEGACY_LIST_COLUMNS` fallback, same reasoning.
- `flagEnabled` on the wire (U2 noted U10 retires it once no client
reads it) — four clients still branch on it; retiring it is a
client-shape change that belongs with U11's shape work.
- `GET /api/tasks?column=` still validates against `COLUMNS`, rejecting
a workflow-declared custom column as a list filter. Server-side filter
surface, not a rendering decision.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
123 lines
4.2 KiB
TypeScript
123 lines
4.2 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import {
|
|
makeTask,
|
|
noop,
|
|
noopDelete,
|
|
noopMerge,
|
|
noopMove,
|
|
noopOpenDetail,
|
|
setupTaskDetailModalHooks,
|
|
} from "./TaskDetailModal.test-helpers";
|
|
import { TaskDetailModal } from "../TaskDetailModal";
|
|
import { fetchBoardWorkflows } from "../../api";
|
|
import type { Column } from "@fusion/core";
|
|
|
|
/*
|
|
FNXC:WorkflowResolvedColumns 2026-07-27-15:20 (U10 / R8):
|
|
Task Detail is the surface an operator opens when a card looks wrong, so it must describe the
|
|
card in the card's OWN workflow vocabulary. Two decisions were keyed on legacy column ids and
|
|
therefore silently wrong for a renamed lane: the header column badge (rendered the raw stored id)
|
|
and the title/description edit affordance (`EDITABLE_COLUMNS = {triage, todo}` — a renamed
|
|
planning lane could not be edited at all, with no error to explain why).
|
|
*/
|
|
|
|
setupTaskDetailModalHooks();
|
|
|
|
const RENAMED_WORKFLOW = {
|
|
id: "wf-renamed",
|
|
name: "Renamed Flow",
|
|
columns: [
|
|
{ id: "backlog", name: "Backlog", flags: { intake: true } },
|
|
{ id: "staging", name: "Ready to build", flags: { hold: true } },
|
|
{ id: "building", name: "Building", flags: { countsTowardWip: true } },
|
|
{ id: "shipped", name: "Shipped", flags: { complete: true } },
|
|
],
|
|
};
|
|
|
|
function mockRenamedWorkflow(taskId: string) {
|
|
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
|
flagEnabled: true,
|
|
defaultWorkflowId: RENAMED_WORKFLOW.id,
|
|
workflows: [RENAMED_WORKFLOW],
|
|
taskWorkflowIds: { [taskId]: RENAMED_WORKFLOW.id },
|
|
} as never);
|
|
}
|
|
|
|
function renderDetail(column: string) {
|
|
return render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-099", column: column as Column, title: "Renamed lane card" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
describe("TaskDetailModal — workflow-resolved columns", () => {
|
|
it("labels the header badge with the workflow's column name, not the stored id", async () => {
|
|
mockRenamedWorkflow("FN-099");
|
|
renderDetail("staging");
|
|
|
|
// TaskDetailModal renders through a floating-window portal, so query the document.
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".detail-column-badge")?.textContent).toBe("Ready to build");
|
|
});
|
|
});
|
|
|
|
it("still labels the badge for a column the workflow does not declare", async () => {
|
|
mockRenamedWorkflow("FN-099");
|
|
renderDetail("todo");
|
|
|
|
await waitFor(() => expect(fetchBoardWorkflows).toHaveBeenCalled());
|
|
// No workflow column to name it: fall back to the shared lifecycle label rather than blank.
|
|
expect(document.querySelector(".detail-column-badge")?.textContent?.trim()).toBe("Todo");
|
|
});
|
|
|
|
it("allows editing a card resting in a renamed intake column", async () => {
|
|
mockRenamedWorkflow("FN-099");
|
|
renderDetail("backlog");
|
|
|
|
expect(await screen.findByRole("button", { name: "Edit task" })).toBeTruthy();
|
|
});
|
|
|
|
it("allows editing a card resting in a renamed hold column", async () => {
|
|
mockRenamedWorkflow("FN-099");
|
|
renderDetail("staging");
|
|
|
|
expect(await screen.findByRole("button", { name: "Edit task" })).toBeTruthy();
|
|
});
|
|
|
|
it("does not offer editing in a renamed implementation column", async () => {
|
|
mockRenamedWorkflow("FN-099");
|
|
renderDetail("building");
|
|
|
|
await waitFor(() => expect(fetchBoardWorkflows).toHaveBeenCalled());
|
|
expect(screen.queryByRole("button", { name: "Edit task" })).toBeNull();
|
|
});
|
|
|
|
it("does not offer editing in a renamed complete column", async () => {
|
|
mockRenamedWorkflow("FN-099");
|
|
renderDetail("shipped");
|
|
|
|
await waitFor(() => expect(fetchBoardWorkflows).toHaveBeenCalled());
|
|
expect(screen.queryByRole("button", { name: "Edit task" })).toBeNull();
|
|
});
|
|
|
|
it("keeps the legacy editable columns editable when no workflow metadata resolves", async () => {
|
|
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
|
flagEnabled: true,
|
|
defaultWorkflowId: "",
|
|
workflows: [],
|
|
taskWorkflowIds: {},
|
|
} as never);
|
|
renderDetail("todo");
|
|
|
|
expect(await screen.findByRole("button", { name: "Edit task" })).toBeTruthy();
|
|
});
|
|
});
|