FN-005: remove duplicate planning move target
Prevent duplicate Planning menu entries for in-review task cards. - Filter supplemental legacy move targets against declared visible workflow columns. - Preserve legacy targets when workflow metadata is unavailable and cover default, legacy, hidden, and non-review menus. - Add a patch changeset for the dashboard fix. Files changed: .changeset/fn-005-duplicate-move-to-planning.md | 7 ++ packages/dashboard/app/components/TaskCard.tsx | 33 +++----- .../app/components/__tests__/TaskCard.test.tsx | 95 ++++++++++++++++++++++ 3 files changed, 112 insertions(+), 23 deletions(-) Fusion-Task-Id: FN-005 Fusion-Task-Lineage: 6964d445-446c-449b-a606-a8fc3d87e34d Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-005-duplicate-move-to-planning.md
Normal file
7
.changeset/fn-005-duplicate-move-to-planning.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix duplicate "Move to Planning" entry in the task card menu for review-lane tasks.
|
||||
category: fix
|
||||
dev: TaskCard's supplemental in-review move targets are now filtered against the workflow's declared columns, so the legacy `triage` id is not offered on workflows that no longer declare it.
|
||||
@@ -2881,31 +2881,18 @@ function TaskCardComponent({
|
||||
The retired in-review Move dropdown offered Done (no merge) and Triage in addition to the shared menu model's Todo/In Progress defaults. Fold those targets into this TaskCard-only menu so card consolidation retains every move capability without changing ListView or TaskDetail menus.
|
||||
*/
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-02:10 (CORRECTION of the note this replaced):
|
||||
The previous version of this comment claimed `triage` was a column U11/#2515 had DELETED, making
|
||||
this a live stale-target bug. THAT WAS WRONG, and it shipped. `triage` is a real, present column:
|
||||
|
||||
builtin-coding-workflow-ir.ts:49 { id: "triage", name: "Planning", traits: [{ trait: "intake" }] }
|
||||
|
||||
`triage` (intake) and `todo` (hold) are still SEPARATE columns on the default board, and `triage`
|
||||
also exists in builtin-pr and builtin-lead-generation. I was carrying a merged-planning-column
|
||||
shape from other work in this program and asserted it against the tree without checking the IR.
|
||||
The move target is valid; there is no stale-target bug here.
|
||||
|
||||
WHAT IS ACTUALLY TRUE OF THIS SITE, and why it stays a literal. `column` below is the loop variable
|
||||
over this function's OWN hardcoded `["done", "triage"]` array. The comparison asks "which entry of
|
||||
my own list am I on" in order to pick a label — not "what role does this card's column play".
|
||||
Resolving a trait for a string this code just wrote itself would be meaningless.
|
||||
|
||||
The ARRAY is the part worth revisiting, because it names move targets by id rather than by role,
|
||||
so a workflow that renames those lanes gets targets it cannot show. That is a behaviour question
|
||||
about which targets a review card should offer — and removing or changing a visible menu entry is
|
||||
the UI-affordance change AGENTS requires a Surface Enumeration for (the workflow-row chevron took
|
||||
FN-6115 -> FN-6118 -> FN-6123 for skipping it). Out of scope for a vocabulary conversion, but a
|
||||
real question, unlike the one the old comment invented.
|
||||
FNXC:BoardCardActions 2026-08-18-06:18 (FN-005):
|
||||
Supplemental in-review targets are named by legacy id so card menus retain
|
||||
Done (no merge) and legacy workflow parity. The default workflow no longer
|
||||
declares `triage`; its `todo` column is labelled Planning. Without filtering,
|
||||
COLUMN_LABELS.triage also reads as Planning and duplicates the declared todo
|
||||
target. Only offer a supplemental target when loaded workflow metadata declares
|
||||
a visible column; retain both legacy targets while metadata is unavailable.
|
||||
*/
|
||||
if (isReviewColumn) {
|
||||
for (const column of ["done", "triage"] as const) {
|
||||
const workflowColumn = taskMoveColumns?.find((candidate) => candidate.id === column);
|
||||
if (taskMoveColumns && (!workflowColumn || workflowColumn.flags?.hiddenFromBoard)) continue;
|
||||
if (moveTransitions.some((transition) => transition.column === column)) continue;
|
||||
moveTransitions.push({
|
||||
column,
|
||||
@@ -2927,7 +2914,7 @@ function TaskCardComponent({
|
||||
}
|
||||
}
|
||||
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
|
||||
}, [handleTaskActionArchive, handleTaskActionMove, handleTaskActionRevert, handleTaskActionUnarchive, isRevertable, onArchiveTask, onDeleteTask, onDuplicateTask, onMergeTask, onMoveTask, onPlanningMode, onOpenRefine, onPauseTask, onResetTask, onRetryTask, onRevertTask, onUnarchiveTask, onUnpauseTask, onUpdateTask, t, task.column, taskActionColumnLabel, taskActionMenuModel.actions, taskActionMenuModel.moveTransitions, taskActionMenuModel.reviewAction]);
|
||||
}, [handleTaskActionArchive, handleTaskActionMove, handleTaskActionRevert, handleTaskActionUnarchive, isRevertable, onArchiveTask, onDeleteTask, onDuplicateTask, onMergeTask, onMoveTask, onPlanningMode, onOpenRefine, onPauseTask, onResetTask, onRetryTask, onRevertTask, onUnarchiveTask, onUnpauseTask, onUpdateTask, t, task.column, taskActionColumnLabel, taskActionMenuModel.actions, taskActionMenuModel.moveTransitions, taskActionMenuModel.reviewAction, taskMoveColumns]);
|
||||
const hasContextMenuActions = contextMenuActions.length > 0;
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
|
||||
@@ -4826,6 +4826,101 @@ describe("TaskCard", () => {
|
||||
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "done", undefined);
|
||||
});
|
||||
|
||||
it("offers only declared default-workflow targets from the in-review three-dot menu", async () => {
|
||||
const onMoveTask = vi.fn();
|
||||
const taskMoveColumns = [
|
||||
{ id: "todo" as const, label: "Planning", flags: { hold: true, intake: true } },
|
||||
{ id: "in-progress" as const, label: "In progress", flags: { countsTowardWip: true } },
|
||||
{ id: "in-review" as const, label: "In review", flags: { mergeBlocker: true, humanReview: true }, moveTargets: ["done", "in-progress", "todo"] },
|
||||
{ id: "done" as const, label: "Done", flags: { complete: true } },
|
||||
{ id: "archived" as const, label: "Archived", flags: { archived: true } },
|
||||
];
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "in-review" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onMoveTask={onMoveTask}
|
||||
taskMoveColumns={taskMoveColumns}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
|
||||
|
||||
const planningMoves = screen.getAllByRole("menuitem", { name: "Move to Planning" });
|
||||
expect(planningMoves).toHaveLength(1);
|
||||
expect(screen.queryByRole("menuitem", { name: "Move to Triage" })).toBeNull();
|
||||
|
||||
fireEvent.click(planningMoves[0]);
|
||||
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "todo", undefined);
|
||||
await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
|
||||
await waitFor(() => expect(screen.getAllByRole("menuitem", { name: "Move to Planning" })).toHaveLength(1));
|
||||
expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the declared legacy triage target and hides hidden supplemental targets", () => {
|
||||
const onMoveTask = vi.fn();
|
||||
const legacyMoveColumns = [
|
||||
{ id: "triage" as const, label: "Planning", flags: { intake: true } },
|
||||
{ id: "todo" as const, label: "Todo", flags: { hold: true } },
|
||||
{ id: "in-progress" as const, label: "In progress", flags: { countsTowardWip: true } },
|
||||
{ id: "in-review" as const, label: "In review", flags: { mergeBlocker: true }, moveTargets: ["todo", "in-progress"] },
|
||||
{ id: "done" as const, label: "Done", flags: { complete: true } },
|
||||
];
|
||||
const { rerender } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "in-review" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onMoveTask={onMoveTask}
|
||||
taskMoveColumns={legacyMoveColumns}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
|
||||
const planningMoves = screen.getAllByRole("menuitem", { name: "Move to Planning" });
|
||||
expect(planningMoves).toHaveLength(1);
|
||||
fireEvent.click(planningMoves[0]);
|
||||
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "triage", undefined);
|
||||
|
||||
rerender(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "in-review" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onMoveTask={onMoveTask}
|
||||
taskMoveColumns={legacyMoveColumns.map((column) => column.id === "done" ? { ...column, flags: { ...column.flags, hiddenFromBoard: true } } : column)}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
|
||||
expect(screen.queryByRole("menuitem", { name: "Done (no merge)" })).toBeNull();
|
||||
expect(screen.getAllByRole("menuitem", { name: "Move to Planning" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("leaves non-review menu transitions unchanged when workflow metadata is present", () => {
|
||||
const taskMoveColumns = [
|
||||
{ id: "todo" as const, label: "Planning", flags: { hold: true } },
|
||||
{ id: "in-progress" as const, label: "In progress", flags: { countsTowardWip: true }, moveTargets: ["todo", "done"] },
|
||||
{ id: "done" as const, label: "Done", flags: { complete: true } },
|
||||
];
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "in-progress" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onMoveTask={vi.fn()}
|
||||
taskMoveColumns={taskMoveColumns}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
|
||||
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy();
|
||||
expect(screen.queryByRole("menuitem", { name: "Done (no merge)" })).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the three-dot menu for every in-progress move target without a Send back shell", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
|
||||
Reference in New Issue
Block a user