FN-6060: add archive column to workflow mode board

Show archived workflow cards in a dedicated board column.

- render the selected workflow's archived column instead of filtering archived tasks out of workflow mode
- keep the workflow archived column collapsible and preserve archive-all behavior on the done column
- extend Board workflow tests to cover archived-column rendering, collapse behavior, and workflows without an archived column

Files changed:
 packages/dashboard/app/components/Board.tsx        | 52 +++++++++++++++++++++-
 .../app/components/__tests__/Board.test.tsx        | 48 +++++++++++++++++---
 2 files changed, 92 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-6060

Fusion-Task-Lineage: 9788bac5-9ccc-427c-a0dd-3cc7f818487a
This commit is contained in:
gsxdsm
2026-06-09 04:02:27 -07:00
parent ece4a37745
commit acd9c669fa
2 changed files with 92 additions and 8 deletions

View File

@@ -391,12 +391,16 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
const selectedWorkflowTasks = useMemo(() => {
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return [];
return tasks.filter((task) => {
if (task.column === "archived") return false;
const workflowId = boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId;
return workflowId === selectedWorkflow.id;
});
}, [boardWorkflows, selectedWorkflow, tasks, workflowMode]);
const selectedWorkflowArchivedColumn = useMemo(() => {
if (!selectedWorkflow) return null;
return selectedWorkflow.columns.find((column) => column.flags.archived) ?? null;
}, [selectedWorkflow]);
const selectedWorkflowColumns = useMemo(() => {
if (!selectedWorkflow) return [];
return selectedWorkflow.columns.filter((column) => !column.flags.archived && !column.flags.hiddenFromBoard);
@@ -584,9 +588,55 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
autoMerge={autoMerge}
{...(isCreateColumn ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge } : {})}
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
/>
);
})}
{selectedWorkflowArchivedColumn && (
<Column
key={selectedWorkflowArchivedColumn.id}
column={selectedWorkflowArchivedColumn.id as ColumnType}
workflowMode
workflowId={selectedWorkflow.id}
columnDisplayName={selectedWorkflowArchivedColumn.name}
columnFlags={selectedWorkflowArchivedColumn.flags}
tasks={selectedWorkflowTasksByColumn[selectedWorkflowArchivedColumn.id] ?? []}
allTasks={selectedWorkflowTasks}
projectId={projectId}
maxConcurrent={maxConcurrent}
onMoveTask={onMoveTask}
onPromote={handlePromote}
canDropTask={(taskId) => canDropTask(taskId, selectedWorkflowArchivedColumn.id, selectedWorkflow.id)}
getDraggingTaskId={getDraggingTaskId}
onPauseTask={onPauseTask}
onOpenDetail={onOpenDetail}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onDeleteTask={onDeleteTask}
availableModels={availableModels}
onOpenDetailWithTab={onOpenDetailWithTab}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
isSearchActive={isSearchActive}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}
collapsed={archivedCollapsed}
onToggleCollapse={handleToggleArchivedCollapse}
/>
)}
</main>
</div>
);

View File

@@ -48,10 +48,10 @@ const columnRenderCounts: Record<string, number> = {};
// Mock child components so we only test Board's own rendering
vi.mock("../Column", () => ({
Column: React.memo(({ column, tasks, onToggleCollapse, onQuickCreate, onNewTask, onToggleAutoMerge, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, workflowStepNameLookup }: { column: string; tasks: Task[]; onToggleCollapse?: () => void; onQuickCreate?: unknown; onNewTask?: unknown; onToggleAutoMerge?: () => void; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; isSearchActive?: boolean; workflowStepNameLookup?: ReadonlyMap<string, string> }) => {
Column: React.memo(({ column, tasks, collapsed, onToggleCollapse, onQuickCreate, onNewTask, onToggleAutoMerge, onArchiveAllDone, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, workflowStepNameLookup }: { column: string; tasks: Task[]; collapsed?: boolean; onToggleCollapse?: () => void; onQuickCreate?: unknown; onNewTask?: unknown; onToggleAutoMerge?: () => void; onArchiveAllDone?: unknown; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; isSearchActive?: boolean; workflowStepNameLookup?: ReadonlyMap<string, string> }) => {
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
return (
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-has-quick-create={onQuickCreate ? "yes" : "no"} data-has-new-task={onNewTask ? "yes" : "no"} data-has-auto-merge-toggle={onToggleAutoMerge ? "yes" : "no"} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"} data-workflow-lookup-size={String(workflowStepNameLookup?.size ?? 0)}>
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-collapsed={collapsed ? "true" : "false"} data-has-quick-create={onQuickCreate ? "yes" : "no"} data-has-new-task={onNewTask ? "yes" : "no"} data-has-auto-merge-toggle={onToggleAutoMerge ? "yes" : "no"} data-has-archive-all={onArchiveAllDone ? "yes" : "no"} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"} data-workflow-lookup-size={String(workflowStepNameLookup?.size ?? 0)}>
{onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>}
</div>
);
@@ -1183,13 +1183,14 @@ describe("Board", () => {
expect([...selector.options].map((option) => option.value)).toEqual(["builtin:coding", "wf-custom"]);
});
it("excludes archived cards from the selected workflow board", async () => {
it("renders archived cards in the selected workflow archived column", async () => {
enableFlag({ "FN-1": "builtin:coding", "FN-9": "builtin:coding" });
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-9", column: "archived" })] });
await waitFor(() => expect(screen.getByTestId("column-todo")).toBeDefined());
const ids = JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id);
expect(ids).toEqual(["FN-1"]);
expect(screen.queryByTestId("column-archived")).toBeNull();
await waitFor(() => expect(screen.getByTestId("column-archived")).toBeDefined());
const todoIds = JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id);
expect(todoIds).toEqual(["FN-1"]);
const archivedIds = JSON.parse(screen.getByTestId("column-archived").getAttribute("data-tasks") || "[]").map((task: Task) => task.id);
expect(archivedIds).toEqual(["FN-9"]);
});
it("renders selected workflow columns as direct children of the horizontal board", async () => {
@@ -1202,8 +1203,41 @@ describe("Board", () => {
"column-in-progress",
"column-in-review",
"column-done",
"column-archived",
]);
});
it("archived column is collapsible in workflow mode", async () => {
enableFlag({ "FN-9": "builtin:coding" });
renderBoard({ tasks: [mkTask({ id: "FN-9", column: "archived" })] });
const archivedColumn = await screen.findByTestId("column-archived");
expect(archivedColumn.getAttribute("data-collapsed")).toBe("true");
fireEvent.click(screen.getByRole("button", { name: "toggle-archived" }));
expect(screen.getByTestId("column-archived").getAttribute("data-collapsed")).toBe("false");
fireEvent.click(screen.getByRole("button", { name: "toggle-archived" }));
expect(screen.getByTestId("column-archived").getAttribute("data-collapsed")).toBe("true");
});
it("workflow without archived column does not render one", async () => {
enableFlag({ "FN-1": CUSTOM_WORKFLOW.id }, [CUSTOM_WORKFLOW]);
renderBoard({ tasks: [mkTask({ id: "FN-1", column: "intake" })] });
await waitFor(() => expect(screen.getByTestId("column-intake")).toBeDefined());
expect(screen.queryByTestId("column-archived")).toBeNull();
});
it("done column in workflow mode receives onArchiveAllDone prop", async () => {
const onArchiveAllDone = vi.fn();
enableFlag({ "FN-1": "builtin:coding" });
renderBoard({ tasks: [mkTask({ id: "FN-1", column: "done" })], onArchiveAllDone });
await waitFor(() => expect(screen.getByTestId("column-done")).toBeDefined());
expect(screen.getByTestId("column-done").getAttribute("data-has-archive-all")).toBe("yes");
expect(screen.getByTestId("column-todo").getAttribute("data-has-archive-all")).toBe("no");
});
});
describe("workflow:updated SSE invalidation (#1406)", () => {