FN-7260: consolidate Done actions into column menu

Move Done column archive and sort controls into the shared column actions menu.

- Replace separate Done sort and archive header controls with menu items.
- Preserve Done sort mode selection and archive-all confirmation behavior for standard and workflow complete columns.
- Update menu styling, dashboard docs, release notes, and Column component coverage.

Files changed:
 .changeset/fn-7260-done-actions-menu.md            |   7 ++
 docs/dashboard-guide.md                            |   5 +-
 packages/dashboard/app/components/Column.tsx       |  93 ++++++++++------
 .../app/components/__tests__/Column.test.tsx       | 118 ++++++++++++---------
 packages/dashboard/app/styles.css                  |  61 +++++------
 5 files changed, 168 insertions(+), 116 deletions(-)

Fusion-Task-Id: FN-7260

Fusion-Task-Lineage: e336aa55-9dda-47bf-b8a7-9d9bbf6a33ef

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 07:58:55 -07:00
parent d04ee5b4c3
commit a039c93f57
5 changed files with 179 additions and 127 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Group Done column sort and archive actions in one accessible actions menu.
category: fix
dev: Updates dashboard Done/complete column headers to use the shared column actions dropdown.

View File

@@ -145,8 +145,9 @@ FNXC:BoardCardActions 2026-06-30-13:12: Card context menus must not label an act
<!-- FNXC:WorkflowBadges 2026-06-30-09:10: Task cards and task detail need workflow-name badges wherever mixed-workflow board contexts can hide the selected lane, especially the Board-only All workflows aggregate. -->
<!-- FNXC:BoardDoneSorting 2026-06-29-00:00: The Done board column exposes a local descending sort selector so operators can review either latest completions or highest task IDs without changing other lifecycle columns. -->
<!-- FNXC:BoardDoneSorting 2026-06-29-20:28: Document both Done sort modes as descending-only and Done-column-only so legacy Done and workflow complete-lane operators understand the selector does not change other lifecycle columns. -->
- Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` defaults to most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback) and can be switched from the Done column header to descending task ID. In workflow mode, non-archived columns marked with the `complete` flag use the same Done ordering even when their column ID or label is customized.
- Done-column sorting has two descending modes: **Completion date (newest first)** keeps the default completion-time order, while **Task ID (newest first)** places the highest numeric task IDs first. The selector is only shown on Done/complete columns, including custom workflow completion lanes.
<!-- FNXC:BoardDoneActions 2026-06-30-00:00: Done/complete column sort choices and Archive All Done now live in the column actions dropdown, so docs must point operators to the menu instead of separate header controls. -->
- Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` defaults to most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback) and can be switched from the Done/complete column actions dropdown to descending task ID. In workflow mode, non-archived columns marked with the `complete` flag use the same Done menu items even when their column ID or label is customized.
- Done-column sorting has two descending modes: **Completion date (newest first)** keeps the default completion-time order, while **Task ID (newest first)** places the highest numeric task IDs first. The sort actions are only shown in Done/complete column action menus, including custom workflow completion lanes; Archive All Done lives in the same menu when available.
- On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll.
<!-- FNXC:WorkflowSelection 2026-06-29-13:34: Board, List, Header, and Graph workflow selectors now share a durable per-project selection so operators return to the same lane after remounts, task refreshes, or respecification flows; stale saved workflow ids must fall back to a valid default/first workflow instead of hiding all tasks. -->
<!-- FNXC:WorkflowSelection 2026-06-29-18:37: The Board-only All workflows option renders an aggregate column set across workflows while keeping workflow-specific creates, edits, and durable selection scoped to real workflow ids. -->

View File

@@ -12,7 +12,7 @@ import { PluginSlot } from "./PluginSlot";
import { groupByWorktree } from "../utils/worktreeGrouping";
import type { ToastType } from "../hooks/useToast";
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react";
import { ChevronDown, ChevronUp, MoreVertical } from "lucide-react";
import type { ModelInfo, BoardWorkflowColumnFlags } from "../api";
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
import type { DoneColumnSortMode } from "./taskSorting";
@@ -565,9 +565,22 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
*/
const isDoneSortColumn = workflowMode ? columnFlags?.complete === true && columnFlags?.archived !== true : column === "done";
const showDoneSortControl = isDoneSortColumn && doneSortMode !== undefined && !!onDoneSortModeChange;
const showDoneArchiveAction = isDoneSortColumn && !!onArchiveAllDone;
const hasDoneMenuActions = showDoneSortControl || showDoneArchiveAction;
const hasColumnMenu = hasColumnBulkActions || hasDoneMenuActions;
const doneSortControlLabel = t("column.doneSortControlLabel", "Sort Done tasks");
const doneSortOptions: Array<{ mode: DoneColumnSortMode; label: string }> = [
{ mode: "completion-date-desc", label: t("column.doneSortCompletionDateDesc", "Completion date (newest first)") },
{ mode: "task-id-desc", label: t("column.doneSortTaskIdDesc", "Task ID (newest first)") },
];
const handleDoneSortModeSelect = useCallback((mode: DoneColumnSortMode) => {
onDoneSortModeChange?.(mode);
setIsMenuOpen(false);
}, [onDoneSortModeChange]);
const handleArchiveAll = useCallback(async () => {
setIsMenuOpen(false);
if (!onArchiveAllDone) return;
if (tasks.length === 0) return;
@@ -614,35 +627,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
+ {t("column.newTask", "New Task")}
</button>
)}
{showDoneSortControl && (
<label className="done-sort-control" title={doneSortControlLabel}>
{/*
FNXC:DoneColumnSorting 2026-06-29-18:09:
The Done header needs an accessible, Done-only control that preserves Archive All and other header actions while allowing operators to switch between completion-date-desc and task-id-desc display orders.
*/}
<span className="done-sort-control__label">{t("column.doneSortLabel", "Sort")}</span>
<select
className="done-sort-control__select"
aria-label={doneSortControlLabel}
value={doneSortMode}
onChange={(event) => onDoneSortModeChange(event.target.value as DoneColumnSortMode)}
>
<option value="completion-date-desc">{t("column.doneSortCompletionDateDesc", "Completion date (newest first)")}</option>
<option value="task-id-desc">{t("column.doneSortTaskIdDesc", "Task ID (newest first)")}</option>
</select>
</label>
)}
{column === "done" && onArchiveAllDone && (
<button
className="btn btn-icon btn-sm"
onClick={handleArchiveAll}
disabled={tasks.length === 0}
title={t("column.archiveAllDoneTitle", "Archive all done tasks")}
aria-label={t("column.archiveAllDoneAriaLabel", "Archive all done tasks")}
>
<Archive />
</button>
)}
{isArchived && onToggleCollapse && (
<button
className="btn btn-icon btn-sm"
@@ -654,15 +639,19 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
{collapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
</button>
)}
{hasColumnBulkActions && (
{hasColumnMenu && (
<div className="column-menu" ref={menuRef}>
{/**
FNXC:DoneColumnActions 2026-06-30-00:00:
Done and workflow complete-column archive/sort affordances must share this column actions dropdown with existing bulk actions, preventing duplicate header controls on desktop and mobile while preserving the original sort modes and archive confirmation path.
*/}
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => setIsMenuOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={isMenuOpen}
aria-label={t("column.actionsAriaLabel", "{{columnLabel}} column actions", { columnLabel: workflowMode ? (columnDisplayName ?? column) : COLUMN_LABELS[column] })}
aria-label={t("column.actionsAriaLabel", "{{columnLabel}} column actions", { columnLabel: columnLabelText })}
title={t("column.actionsTitle", "Column actions")}
disabled={isMenuBusy}
>
@@ -670,6 +659,46 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
</button>
{isMenuOpen && (
<div className="column-menu-popover" role="menu">
{showDoneSortControl && (
<div className="column-menu-group" role="group" aria-label={doneSortControlLabel}>
{doneSortOptions.map((option) => (
<button
key={option.mode}
type="button"
role="menuitemradio"
aria-checked={doneSortMode === option.mode}
className="column-menu-item column-menu-item-radio"
onClick={() => handleDoneSortModeSelect(option.mode)}
>
<span className="column-menu-item-row">
<span className="column-menu-item-check" aria-hidden="true">{doneSortMode === option.mode ? "✓" : ""}</span>
<span>{option.label}</span>
</span>
<span className="column-menu-item-hint">
{option.mode === "completion-date-desc"
? t("column.doneSortCompletionDateDescHint", "Show recently completed tasks first")
: t("column.doneSortTaskIdDescHint", "Show highest task IDs first")}
</span>
</button>
))}
</div>
)}
{showDoneArchiveAction && (
<button
type="button"
role="menuitem"
className="column-menu-item"
onClick={() => void handleArchiveAll()}
disabled={tasks.length === 0}
>
{t("column.archiveAllDoneTitle", "Archive all done tasks")}
<span className="column-menu-item-hint">
{tasks.length === 0
? t("column.noDoneTasksToArchive", "No done tasks to archive")
: t("column.archiveAllDoneHint", "Archive {{count}} done task{{plural}}", { count: tasks.length, plural: tasks.length === 1 ? "" : "s" })}
</span>
</button>
)}
{isTodoLikeColumn && (
<button
type="button"

View File

@@ -651,28 +651,37 @@ describe("Column in-progress/in-review bulk actions", () => {
});
});
describe("Column Done sort control", () => {
it("renders an accessible Done-only sort selector with clear labels", () => {
render(
describe("Column Done action menu", () => {
it("renders one accessible Done actions dropdown with sort choices and archive", async () => {
const user = userEvent.setup();
const { container } = render(
<Column
{...defaultProps}
column="done"
tasks={[{ ...makeTask("FN-001"), column: "done" }]}
onArchiveAllDone={vi.fn().mockResolvedValue([])}
doneSortMode="completion-date-desc"
onDoneSortModeChange={vi.fn()}
/>,
);
const select = screen.getByRole("combobox", { name: "Sort Done tasks" });
expect(select.closest(".done-sort-control")).toHaveAttribute("title", "Sort Done tasks");
expect(screen.getByText("Sort")).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Completion date (newest first)" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Task ID (newest first)" })).toBeInTheDocument();
expect(select.closest(".done-sort-control")).not.toBeNull();
expect(select.closest(".column-header")).not.toBeNull();
const header = screen.getByRole("heading", { name: "Done" }).closest(".column-header") as HTMLElement;
const actionsButton = screen.getByRole("button", { name: "Done column actions" });
expect(actionsButton.closest(".column-header")).toBe(header);
expect(header.querySelectorAll(".column-menu")).toHaveLength(1);
expect(screen.queryByRole("combobox", { name: "Sort Done tasks" })).toBeNull();
expect(container.querySelector(".done-sort-control")).toBeNull();
expect(screen.queryByRole("button", { name: "Archive all done tasks" })).toBeNull();
await user.click(actionsButton);
expect(screen.getByRole("menuitemradio", { name: /Completion date \(newest first\)/ })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("menuitemradio", { name: /Task ID \(newest first\)/ })).toHaveAttribute("aria-checked", "false");
expect(screen.getByRole("menuitem", { name: /Archive all done tasks/i })).toBeEnabled();
});
it("renders the selector for workflow complete columns with custom ids", () => {
it("renders the same Done dropdown for workflow complete columns with custom ids", async () => {
const user = userEvent.setup();
render(
<Column
{...defaultProps}
@@ -681,16 +690,21 @@ describe("Column Done sort control", () => {
columnDisplayName="Shipped"
columnFlags={{ complete: true }}
tasks={[{ ...makeTask("FN-001"), column: "shipped" as ColumnType }]}
onArchiveAllDone={vi.fn().mockResolvedValue([])}
doneSortMode="completion-date-desc"
onDoneSortModeChange={vi.fn()}
/>,
);
expect(screen.getByRole("heading", { name: "Shipped" })).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Sort Done tasks" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Shipped column actions" }));
expect(screen.getByRole("menuitemradio", { name: /Completion date \(newest first\)/ })).toBeInTheDocument();
expect(screen.getByRole("menuitemradio", { name: /Task ID \(newest first\)/ })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: /Archive all done tasks/i })).toBeInTheDocument();
});
it("selects task ID descending from the Done header", async () => {
it("selects task ID descending from the Done actions menu", async () => {
const user = userEvent.setup();
const onDoneSortModeChange = vi.fn();
render(
@@ -703,12 +717,14 @@ describe("Column Done sort control", () => {
/>,
);
await user.selectOptions(screen.getByRole("combobox", { name: "Sort Done tasks" }), "task-id-desc");
await user.click(screen.getByRole("button", { name: "Done column actions" }));
await user.click(screen.getByRole("menuitemradio", { name: /Task ID \(newest first\)/ }));
expect(onDoneSortModeChange).toHaveBeenCalledWith("task-id-desc");
expect(screen.queryByRole("menu")).toBeNull();
});
it("selects completion-date descending from the Done header", async () => {
it("selects completion-date descending from the Done actions menu", async () => {
const user = userEvent.setup();
const onDoneSortModeChange = vi.fn();
render(
@@ -721,70 +737,69 @@ describe("Column Done sort control", () => {
/>,
);
await user.selectOptions(screen.getByRole("combobox", { name: "Sort Done tasks" }), "completion-date-desc");
await user.click(screen.getByRole("button", { name: "Done column actions" }));
await user.click(screen.getByRole("menuitemradio", { name: /Completion date \(newest first\)/ }));
expect(onDoneSortModeChange).toHaveBeenCalledWith("completion-date-desc");
expect(screen.queryByRole("menu")).toBeNull();
});
it("keeps the Done sort selector available when Done is empty", () => {
it("archives Done tasks from the menu only after confirmation", async () => {
const user = userEvent.setup();
const onArchiveAllDone = vi.fn().mockResolvedValue([{ ...makeTask("FN-001"), column: "archived" }]);
render(
<Column
{...defaultProps}
column="done"
tasks={[{ ...makeTask("FN-001"), column: "done" }]}
onArchiveAllDone={onArchiveAllDone}
doneSortMode="completion-date-desc"
onDoneSortModeChange={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Done column actions" }));
await user.click(screen.getByRole("menuitem", { name: /Archive all done tasks/i }));
await waitFor(() => expect(onArchiveAllDone).toHaveBeenCalledTimes(1));
expect(mockConfirm).toHaveBeenCalledWith({
title: "Archive All Done",
message: "Archive all 1 done tasks?",
danger: true,
});
});
it("keeps sort choices available while blocking archive for an empty Done column", async () => {
const user = userEvent.setup();
const onArchiveAllDone = vi.fn().mockResolvedValue([]);
render(
<Column
{...defaultProps}
column="done"
tasks={[]}
onArchiveAllDone={onArchiveAllDone}
doneSortMode="completion-date-desc"
onDoneSortModeChange={vi.fn()}
/>,
);
expect(screen.getByRole("combobox", { name: "Sort Done tasks" })).toBeInTheDocument();
expect(screen.getByText("0")).toHaveClass("column-count");
await user.click(screen.getByRole("button", { name: "Done column actions" }));
expect(screen.getByRole("menuitemradio", { name: /Completion date \(newest first\)/ })).toBeInTheDocument();
expect(screen.getByRole("menuitemradio", { name: /Task ID \(newest first\)/ })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: /Archive all done tasks/i })).toBeDisabled();
expect(onArchiveAllDone).not.toHaveBeenCalled();
expect(mockConfirm).not.toHaveBeenCalled();
});
it("coexists with Archive All Done without disabling sort selection", () => {
render(
<Column
{...defaultProps}
column="done"
tasks={[{ ...makeTask("FN-001"), column: "done" }]}
onArchiveAllDone={vi.fn().mockResolvedValue([])}
doneSortMode="completion-date-desc"
onDoneSortModeChange={vi.fn()}
/>,
);
expect(screen.getByRole("combobox", { name: "Sort Done tasks" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Archive all done tasks" })).toBeEnabled();
const header = screen.getByRole("heading", { name: "Done" }).closest(".column-header") as HTMLElement;
expect(header.querySelector(".done-sort-control")).not.toBeNull();
expect(header.querySelector(".btn-icon")).not.toBeNull();
});
it("keeps Done header actions in the wrapping-friendly header structure", () => {
render(
<Column
{...defaultProps}
column="done"
tasks={[{ ...makeTask("FN-001"), column: "done" }]}
onArchiveAllDone={vi.fn().mockResolvedValue([])}
doneSortMode="completion-date-desc"
onDoneSortModeChange={vi.fn()}
/>,
);
const header = screen.getByRole("heading", { name: "Done" }).closest(".column-header") as HTMLElement;
expect(header).toBeInTheDocument();
expect(header.querySelector(".column-count")?.textContent).toBe("1");
expect(screen.getByRole("combobox", { name: "Sort Done tasks" }).closest(".done-sort-control")?.parentElement).toBe(header);
expect(screen.getByRole("button", { name: "Archive all done tasks" }).parentElement).toBe(header);
});
it("hides the sort control and leaves no wrapper on non-Done columns", () => {
it("hides Done menu items and leaves no standalone wrappers on non-Done columns", async () => {
const user = userEvent.setup();
const { container } = render(
<Column
{...defaultProps}
column="todo"
tasks={[{ ...makeTask("FN-001"), column: "todo" }]}
onArchiveAllDone={vi.fn().mockResolvedValue([])}
doneSortMode="completion-date-desc"
onDoneSortModeChange={vi.fn()}
/>,
@@ -793,9 +808,15 @@ describe("Column Done sort control", () => {
expect(screen.queryByRole("combobox", { name: "Sort Done tasks" })).toBeNull();
expect(container.querySelector(".done-sort-control")).toBeNull();
expect(container.querySelector("[aria-label='Sort Done tasks']")).toBeNull();
await user.click(screen.getByRole("button", { name: "Todo column actions" }));
expect(screen.queryByRole("menuitemradio", { name: /Completion date \(newest first\)/ })).toBeNull();
expect(screen.queryByRole("menuitemradio", { name: /Task ID \(newest first\)/ })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /Archive all done tasks/i })).toBeNull();
});
it("hides the sort control on Done when sort props are absent", () => {
it("does not render a Done actions menu when Done sort and archive props are absent", () => {
const { container } = render(
<Column
{...defaultProps}
@@ -804,6 +825,7 @@ describe("Column Done sort control", () => {
/>,
);
expect(screen.queryByRole("button", { name: "Done column actions" })).toBeNull();
expect(screen.queryByRole("combobox", { name: "Sort Done tasks" })).toBeNull();
expect(container.querySelector(".done-sort-control")).toBeNull();
});

View File

@@ -1174,40 +1174,6 @@ body {
animation: count-flash-bg 1400ms ease-out;
}
/*
FNXC:DoneColumnSorting 2026-06-29-18:11:
The Done sort selector lives in the column header beside Archive All; compact inline-flex styling keeps desktop headers tight while flex wrapping lets narrow/mobile headers move the selector without leaving empty action shells on non-Done columns.
*/
.done-sort-control {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
min-width: 0;
}
.done-sort-control__label {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
}
.done-sort-control__select {
max-width: 150px;
min-height: 28px;
min-width: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-size: 0.75rem;
padding: calc(var(--space-xs) / 2) var(--space-sm);
}
.done-sort-control__select:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.column-desc {
font-size: 0.6875rem;
color: var(--text-dim);
@@ -3899,6 +3865,15 @@ Toast text must contrast its status background across every dashboard theme and
flex-direction: column;
}
.column-menu-group {
display: flex;
flex-direction: column;
gap: calc(var(--space-xs) / 2);
padding-bottom: var(--space-xs);
margin-bottom: var(--space-xs);
border-bottom: 1px solid var(--border);
}
.column-menu-item {
display: flex;
flex-direction: column;
@@ -3930,6 +3905,24 @@ Toast text must contrast its status background across every dashboard theme and
color: var(--text-muted);
}
.column-menu-item-row {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.column-menu-item-check {
width: 1em;
color: var(--color-success);
text-align: center;
}
@media (max-width: 768px) {
.column-menu-popover {
max-width: calc(100vw - (var(--space-lg) * 2));
}
}
/*
FNXC:Navigation 2026-06-22-00:00:
Board card clicks open task detail as a full main-content view that replaces the board ("Full main panel" design). This layout fills the main content area with a scrollable embedded TaskDetailContent body. Theme tokens only; mobile shell renders it unchanged because the panel just fills its host.