fix(dashboard): let Start work on a copied Ideas workflow, not just the built-in one
Start resolved its create-time Planning lane from the literal builtin:coding-ideas id, so a duplicated Ideas workflow fell through to a promotion that skipped the Planning hold lane and targeted the WIP lane. Column adjacency permits intake -> hold | archived only, so that move was rejected and the card stayed parked in Ideas. Resolve the lane from traits (first declared hold column immediately after a manual intake, mirroring resolveWorkflowIntakeFacts), and promote exactly one legal forward step when no atomic lane can be proven.
This commit is contained in:
7
.changeset/quick-add-start-duplicated-ideas-workflow.md
Normal file
7
.changeset/quick-add-start-duplicated-ideas-workflow.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix Start in the task composer doing nothing on a duplicated Ideas workflow.
|
||||
category: fix
|
||||
dev: `resolveQuickAddStartInitialColumn` no longer keys on the literal `builtin:coding-ideas` id — a manual-intake workflow now resolves its create-time Planning lane from traits (first declared `hold` column immediately after the intake), mirroring `resolveWorkflowIntakeFacts`'s unplanned-Start classification in `packages/core/src/task-store/task-creation.ts`. `resolveQuickAddStartTargetColumn` promotes exactly one legal forward step (hold lanes included) instead of skipping holds into the WIP lane, which column adjacency always rejected (`intake -> hold | archived`). Covers both Start surfaces: QuickEntryBox and NewTaskModal.
|
||||
@@ -1785,6 +1785,43 @@ describe("NewTaskModal", () => {
|
||||
expect(props.addToast).toHaveBeenCalledWith("Queued FN-START for planning", "success");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:QuickAddStart 2026-08-26-19:19:
|
||||
Second Start surface for the duplicated-Ideas-workflow fix: the New Task dialog shares
|
||||
`quickAddStart`'s resolution with the board composer, so it must also create in the duplicate's
|
||||
own Planning lane instead of promoting into the WIP lane (a rejected transition that left the
|
||||
card parked in Ideas).
|
||||
*/
|
||||
it("atomically creates a duplicated Ideas workflow's Start in its own Planning lane", async () => {
|
||||
await mockStartWorkflows("WF-014", "Coding ideas V2");
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [{
|
||||
id: "WF-014",
|
||||
name: "Coding ideas V2",
|
||||
columns: [
|
||||
{ id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } },
|
||||
{ id: "todo", name: "Planning", flags: { hold: true } },
|
||||
{ id: "in-progress", name: "In progress", flags: { countsTowardWip: true } },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
],
|
||||
}],
|
||||
taskWorkflowIds: {},
|
||||
} as BoardWorkflowsPayload);
|
||||
const onCreateTask = vi.fn().mockResolvedValue({ ...makeTask("FN-CLONE"), column: "todo", workflowId: "WF-014" });
|
||||
const onMoveTask = vi.fn();
|
||||
renderNewTaskModal({ onCreateTask, onMoveTask });
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
|
||||
await chooseWorkflowOption("WF-014");
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Start this idea" } });
|
||||
fireEvent.click(await screen.findByTestId("task-form-inline-start"));
|
||||
|
||||
await waitFor(() => expect(onCreateTask).toHaveBeenCalledWith(expect.objectContaining({ workflowId: "WF-014", column: "todo" })));
|
||||
expect(onMoveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exposes the same eligible Start action in the desktop floating host", async () => {
|
||||
mockViewportMode = "desktop";
|
||||
await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)");
|
||||
|
||||
@@ -5080,6 +5080,36 @@ describe("QuickEntryBox", () => {
|
||||
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-hold", "working"));
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:QuickAddStart 2026-08-26-19:19:
|
||||
Reported symptom: on a DUPLICATED Ideas workflow ("Coding ideas V2") the composer's Start button
|
||||
created the card but never started it. Start keyed its atomic destination on the literal
|
||||
`builtin:coding-ideas` id, so a copy fell through to a promotion that skipped the Planning hold
|
||||
lane and moved into the WIP lane — a transition the server always rejects. Assert the composer
|
||||
surface creates in the duplicate's own Planning lane and issues no move at all.
|
||||
*/
|
||||
it("creates a duplicated Ideas workflow's Start in its own Planning lane", async () => {
|
||||
const duplicatedIdeasWorkflow = {
|
||||
id: "WF-014",
|
||||
name: "Coding ideas V2",
|
||||
columns: [
|
||||
{ id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } },
|
||||
{ id: "todo", name: "Planning", flags: { hold: true } },
|
||||
{ id: "in-progress", name: "In progress", flags: { countsTowardWip: true } },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
],
|
||||
};
|
||||
const onCreate = vi.fn().mockResolvedValue({ ...CREATED_TASK, id: "FN-clone", column: "todo", workflowId: duplicatedIdeasWorkflow.id });
|
||||
const onMoveTask = vi.fn().mockResolvedValue({});
|
||||
renderQuickEntryBox({ onCreate, onMoveTask, workflowId: duplicatedIdeasWorkflow.id, workflowOptions: [duplicatedIdeasWorkflow] });
|
||||
enterDescription();
|
||||
|
||||
clickStart();
|
||||
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ workflowId: duplicatedIdeasWorkflow.id, column: "todo" })));
|
||||
expect(onMoveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables Start until a description is entered and never hides it mid-typing", () => {
|
||||
renderQuickEntryBox({ onMoveTask: vi.fn(), workflowId: ideasWorkflow.id, workflowOptions: [ideasWorkflow] });
|
||||
|
||||
|
||||
@@ -92,16 +92,94 @@ describe("quick add Start workflow guards", () => {
|
||||
expect(resolveQuickAddStartWorkflowTarget(noTarget)).toBeNull();
|
||||
});
|
||||
|
||||
it("only chooses a later visible working destination", () => {
|
||||
/*
|
||||
FNXC:QuickAddStart 2026-08-26-19:19:
|
||||
Was "only chooses a later visible working destination", which asserted that the promotion skipped
|
||||
`hold` lanes to reach the first working column. Column adjacency never permitted that jump, so the
|
||||
move it described was rejected server-side. The promotion is one legal forward step.
|
||||
*/
|
||||
it("promotes exactly one legal forward step, hold lanes included", () => {
|
||||
const valid = validateQuickAddStartWorkflow(workflow({ columns: [
|
||||
{ id: "ideas", name: "Ideas", flags: { hold: true } },
|
||||
{ id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } },
|
||||
{ id: "review", name: "Review", flags: { hold: true } },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
{ id: "todo", name: "Todo", flags: {} },
|
||||
] }));
|
||||
expect(valid).not.toBeNull();
|
||||
expect(resolveQuickAddStartTargetColumn(valid!, "ideas")).toBe("todo");
|
||||
expect(resolveQuickAddStartTargetColumn(valid!, "ideas")).toBe("review");
|
||||
expect(resolveQuickAddStartTargetColumn(valid!, "review")).toBeNull();
|
||||
expect(resolveQuickAddStartTargetColumn(valid!, "todo")).toBeNull();
|
||||
expect(resolveQuickAddStartTargetColumn(valid!, "unknown")).toBeNull();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:QuickAddStart 2026-08-26-19:19:
|
||||
Regression: a duplicated Ideas workflow ("Coding ideas V2") reported as "Start does not start the
|
||||
task". Start resolved its destination from the literal `builtin:coding-ideas` id, so a copy fell
|
||||
through to a promotion that skipped the Planning hold lane and targeted the WIP lane — a move
|
||||
`intake -> wip` that column adjacency always rejects. Surfaces: both Start callers share these
|
||||
helpers (QuickEntryBox composer and NewTaskModal), so the invariant is asserted here once for the
|
||||
built-in, its duplicate, and the metadata shapes that must fail closed.
|
||||
*/
|
||||
describe("duplicated Ideas workflows", () => {
|
||||
const clone = (overrides: Record<string, unknown> = {}) => validateQuickAddStartWorkflow(workflow({
|
||||
id: "WF-014",
|
||||
name: "Coding ideas V2",
|
||||
columns: [
|
||||
{ id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } },
|
||||
{ id: "todo", name: "Planning", flags: { hold: true } },
|
||||
{ id: "in-progress", name: "In progress", flags: { countsTowardWip: true } },
|
||||
{ id: "in-review", name: "In review", flags: { mergeBlocker: true, humanReview: true } },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
{ id: "archived", name: "Archived", flags: { archived: true, hiddenFromBoard: true } },
|
||||
],
|
||||
...overrides,
|
||||
}));
|
||||
|
||||
it("creates in its own Planning lane instead of jumping into the WIP lane", () => {
|
||||
const duplicate = clone();
|
||||
expect(duplicate).not.toBeNull();
|
||||
expect(workflowSupportsQuickAddStart(duplicate)).toBe(true);
|
||||
expect(resolveQuickAddStartInitialColumn(duplicate!)).toBe("todo");
|
||||
expect(resolveQuickAddStartWorkflowTarget(duplicate)).toBe("todo");
|
||||
// The rejected move that made Start a no-op must be unreachable from the intake lane.
|
||||
expect(resolveQuickAddStartTargetColumn(duplicate!, "ideas")).not.toBe("in-progress");
|
||||
});
|
||||
|
||||
it("fails closed to the promotion path when the planning lane is unprovable", () => {
|
||||
// The server classifies a Start create by the FIRST DECLARED hold column, so an intake that
|
||||
// also holds, or a hidden earlier hold lane, means our visible candidate is the wrong column.
|
||||
const intakeAlsoHolds = clone({ columns: [
|
||||
{ id: "ideas", name: "Ideas", flags: { intake: true, hold: true, manualIntake: true } },
|
||||
{ id: "todo", name: "Planning", flags: { hold: true } },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
] });
|
||||
expect(resolveQuickAddStartInitialColumn(intakeAlsoHolds!)).toBeNull();
|
||||
expect(resolveQuickAddStartWorkflowTarget(intakeAlsoHolds)).toBe("todo");
|
||||
|
||||
const hiddenEarlierHold = clone({ columns: [
|
||||
{ id: "parked", name: "Parked", flags: { hold: true, hiddenFromBoard: true } },
|
||||
{ id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } },
|
||||
{ id: "todo", name: "Planning", flags: { hold: true } },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
] });
|
||||
expect(resolveQuickAddStartInitialColumn(hiddenEarlierHold!)).toBeNull();
|
||||
|
||||
const noPlanningLane = clone({ columns: [
|
||||
{ id: "ideas", name: "Ideas", flags: { intake: true, manualIntake: true } },
|
||||
{ id: "in-progress", name: "In progress", flags: { countsTowardWip: true } },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
] });
|
||||
expect(resolveQuickAddStartInitialColumn(noPlanningLane!)).toBeNull();
|
||||
expect(resolveQuickAddStartWorkflowTarget(noPlanningLane)).toBe("in-progress");
|
||||
|
||||
const autoTriagingIntake = clone({ columns: [
|
||||
{ id: "ideas", name: "Ideas", flags: { intake: true } },
|
||||
{ id: "todo", name: "Planning", flags: { hold: true } },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
] });
|
||||
expect(resolveQuickAddStartInitialColumn(autoTriagingIntake!)).toBeNull();
|
||||
expect(resolveQuickAddStartWorkflowTarget(autoTriagingIntake)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,16 +45,51 @@ export function workflowSupportsQuickAddStart(workflow: ValidatedQuickAddWorkflo
|
||||
* Custom hold-workflow Start promotion uses the returned task's actual column and moves forward only to
|
||||
* a later working column. Missing data, holds, complete lanes, or no later destination are
|
||||
* successful create-only outcomes; Quick Add never guesses `todo` or moves backwards.
|
||||
*
|
||||
* FNXC:QuickAddStart 2026-08-26-19:19:
|
||||
* The forward step is now the IMMEDIATELY following visible column, and a `hold` lane is a legal
|
||||
* destination rather than something to skip. Skipping holds produced a move the server always
|
||||
* refuses: column adjacency permits `intake -> hold | archived` only (ROLE_TRANSITIONS in
|
||||
* packages/core/src/workflows/workflow-transitions.ts), and neighbour-derived adjacency for
|
||||
* genuinely custom shapes permits the next declared column only. Jumping over a Planning hold lane
|
||||
* into the WIP lane therefore returned 409 "Invalid transition: 'ideas' -> 'in-progress'", so Start
|
||||
* created a card that never started. One legal forward step is the only promotion Quick Add can
|
||||
* prove; anything further is the operator's move to make.
|
||||
*/
|
||||
export function resolveQuickAddStartTargetColumn(workflow: ValidatedQuickAddWorkflow, createdColumn: unknown): string | null {
|
||||
if (typeof createdColumn !== "string" || !createdColumn.trim()) return null;
|
||||
const columns = visibleColumns(workflow);
|
||||
const createdIndex = columns.findIndex((column) => column.id === createdColumn);
|
||||
if (createdIndex < 0) return null;
|
||||
for (const column of columns.slice(createdIndex + 1)) {
|
||||
if (!column.flags.intake && !column.flags.hold && !column.flags.complete) return column.id;
|
||||
}
|
||||
return null;
|
||||
const next = columns[createdIndex + 1];
|
||||
if (!next || next.flags.intake || next.flags.complete) return null;
|
||||
return next.id;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:QuickAddStart 2026-08-26-19:19:
|
||||
A DUPLICATED or hand-authored Ideas workflow ("Coding ideas V2") must start exactly like the
|
||||
built-in one. The atomic create-in-Planning path below keys on the literal `builtin:coding-ideas`
|
||||
id, so every copy fell through to the promotion path and its card stayed parked in Ideas.
|
||||
|
||||
The destination is derived from the SAME traits the server uses, not from a name: a create lands in
|
||||
the planning lane pre-planned only when `resolveWorkflowIntakeFacts` classifies it as an unplanned
|
||||
Start create, and that check is `task.column === columnsWithFlag(ir, "hold")[0]` on a manual-intake
|
||||
workflow (packages/core/src/task-store/task-creation.ts). Submitting any OTHER column earns
|
||||
`generateSpecifiedPrompt` instead of the bootstrap seed, and triage only admits seed prompts — the
|
||||
card would sit in Planning looking planned, forever (FN-8587). So the candidate must be the first
|
||||
DECLARED hold column (hidden lanes included, exactly as the server scans them) and must sit
|
||||
immediately after the manual intake. Anything else fails closed to the one-step promotion path,
|
||||
which is legal under column adjacency.
|
||||
*/
|
||||
function resolveManualIntakePlanningColumn(workflow: ValidatedQuickAddWorkflow): string | null {
|
||||
const columns = visibleColumns(workflow);
|
||||
const intake = columns[0];
|
||||
if (!intake || intake.flags.manualIntake !== true) return null;
|
||||
const planning = columns[1];
|
||||
if (!planning || planning.flags.hold !== true || planning.flags.intake || planning.flags.complete) return null;
|
||||
if (workflow.columns.find((column) => column.flags.hold === true)?.id !== planning.id) return null;
|
||||
return planning.id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,7 +115,9 @@ export function resolveQuickAddStartWorkflowTarget(workflow: ValidatedQuickAddWo
|
||||
}
|
||||
|
||||
export function resolveQuickAddStartInitialColumn(workflow: ValidatedQuickAddWorkflow): string | null {
|
||||
if (workflow.id !== "builtin:coding-ideas") return null;
|
||||
/* FNXC:QuickAddStart 2026-08-26-19:19: every other workflow — including a duplicate of this one —
|
||||
resolves its planning lane from traits instead of returning null. */
|
||||
if (workflow.id !== "builtin:coding-ideas") return resolveManualIntakePlanningColumn(workflow);
|
||||
const columns = visibleColumns(workflow);
|
||||
/*
|
||||
DELIBERATE-LITERAL — these are ONE NAMED BUILTIN's own declared ids, not a lifecycle guard.
|
||||
|
||||
Reference in New Issue
Block a user