FN-7238: add Done column sort modes
Add Done-column controls for choosing descending completion-date or task-ID order. - Add a Done-only sort selector shared by legacy and workflow complete columns. - Extend task display sorting with completion-date and task-ID descending modes. - Cover board, column, and sorting behavior with regression tests and docs. - Add a minor changeset for the published CLI package. Files changed: .changeset/fn-7238-done-column-sort.md | 7 + docs/dashboard-guide.md | 5 +- packages/dashboard/app/components/Board.tsx | 28 +++- packages/dashboard/app/components/Column.tsx | 33 ++++- .../app/components/__tests__/Board.test.tsx | 133 ++++++++++++++++- .../app/components/__tests__/Column.test.tsx | 158 +++++++++++++++++++++ .../app/components/__tests__/taskSorting.test.ts | 114 +++++++++++++++ packages/dashboard/app/components/taskSorting.ts | 39 ++++- packages/dashboard/app/styles.css | 35 +++++ 9 files changed, 538 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-7238 Fusion-Task-Lineage: 143722e9-0b59-41e5-954d-810263f18ba9 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7238-done-column-sort.md
Normal file
7
.changeset/fn-7238-done-column-sort.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Let operators sort the board Done column by completion date or task ID.
|
||||||
|
category: feature
|
||||||
|
dev: Adds Done-column-only descending sort modes while preserving existing completion-date default ordering.
|
||||||
@@ -133,7 +133,10 @@ Features:
|
|||||||
- PR/issue badges with live updates
|
- PR/issue badges with live updates
|
||||||
- GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown in the footer with other external-source metadata
|
- GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown in the footer with other external-source metadata
|
||||||
- Task card header meta badges group priority, fast mode, agent-created provenance, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs
|
- Task card header meta badges group priority, fast mode, agent-created provenance, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs
|
||||||
- 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` is ordered by most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback)
|
<!-- 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.
|
||||||
- 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.
|
- 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-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. -->
|
||||||
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Each option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. The open listbox grows from the longest workflow name plus its count/edit decorations while remaining viewport-bounded; the closed trigger stays narrow and ellipsized. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
|
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Each option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. The open listbox grows from the longest workflow name plus its count/edit decorations while remaining viewport-bounded; the closed trigger stays narrow and ellipsized. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } from "@fusion/core";
|
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } from "@fusion/core";
|
||||||
import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core";
|
import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core";
|
||||||
import { sortTasksForDisplayColumn } from "./taskSorting";
|
import { sortTasksForDisplayColumn, type DoneColumnSortMode } from "./taskSorting";
|
||||||
import { Column } from "./Column";
|
import { Column } from "./Column";
|
||||||
import "./Lane.css";
|
import "./Lane.css";
|
||||||
import "./Board.css";
|
import "./Board.css";
|
||||||
@@ -139,6 +139,11 @@ function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) {
|
|||||||
|
|
||||||
export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) {
|
export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) {
|
||||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||||
|
/*
|
||||||
|
FNXC:DoneColumnSorting 2026-06-29-16:57:
|
||||||
|
Board owns one Done sort mode so legacy and built-in workflow Done surfaces stay in sync; the default remains completion-date descending to preserve existing first-load ordering.
|
||||||
|
*/
|
||||||
|
const [doneSortMode, setDoneSortMode] = useState<DoneColumnSortMode>("completion-date-desc");
|
||||||
const archivedLoadedRef = useRef(false);
|
const archivedLoadedRef = useRef(false);
|
||||||
const boardRef = useRef<HTMLElement | null>(null);
|
const boardRef = useRef<HTMLElement | null>(null);
|
||||||
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
|
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
|
||||||
@@ -223,7 +228,9 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
const stableGrouped = {} as Record<ColumnType, Task[]>;
|
const stableGrouped = {} as Record<ColumnType, Task[]>;
|
||||||
|
|
||||||
for (const column of COLUMNS) {
|
for (const column of COLUMNS) {
|
||||||
const sortedTasks = sortTasksForDisplayColumn(nextGrouped[column], column);
|
const sortedTasks = column === "done"
|
||||||
|
? sortTasksForDisplayColumn(nextGrouped[column], column, doneSortMode)
|
||||||
|
: sortTasksForDisplayColumn(nextGrouped[column], column);
|
||||||
stableGrouped[column] = areTaskArraysEqual(previousGrouped[column], sortedTasks)
|
stableGrouped[column] = areTaskArraysEqual(previousGrouped[column], sortedTasks)
|
||||||
? previousGrouped[column]
|
? previousGrouped[column]
|
||||||
: sortedTasks;
|
: sortedTasks;
|
||||||
@@ -231,7 +238,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
|
|
||||||
tasksByColumnCacheRef.current = stableGrouped;
|
tasksByColumnCacheRef.current = stableGrouped;
|
||||||
return stableGrouped;
|
return stableGrouped;
|
||||||
}, [tasks]);
|
}, [tasks, doneSortMode]);
|
||||||
|
|
||||||
// FN-4574 + FN-001 diagnosis: on iOS Safari, the mobile board can occasionally
|
// FN-4574 + FN-001 diagnosis: on iOS Safari, the mobile board can occasionally
|
||||||
// snap against stale layout/visualViewport metrics before flex columns resolve,
|
// snap against stale layout/visualViewport metrics before flex columns resolve,
|
||||||
@@ -432,10 +439,17 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
(grouped[task.column] ??= []).push(task);
|
(grouped[task.column] ??= []).push(task);
|
||||||
}
|
}
|
||||||
for (const column of selectedWorkflow.columns) {
|
for (const column of selectedWorkflow.columns) {
|
||||||
grouped[column.id] = sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType);
|
/*
|
||||||
|
FNXC:DoneColumnSorting 2026-06-29-20:20:
|
||||||
|
Workflow-mode Done sorting follows the workflow trait, not only the built-in `done` id, so custom complete lanes get the same descending completion-date/task-id selector while archived lanes keep their own behavior.
|
||||||
|
*/
|
||||||
|
const isWorkflowDoneLikeColumn = column.flags.complete === true && column.flags.archived !== true;
|
||||||
|
grouped[column.id] = isWorkflowDoneLikeColumn
|
||||||
|
? sortTasksForDisplayColumn(grouped[column.id] ?? [], "done", doneSortMode)
|
||||||
|
: sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType);
|
||||||
}
|
}
|
||||||
return grouped;
|
return grouped;
|
||||||
}, [selectedWorkflow, selectedWorkflowTasks]);
|
}, [doneSortMode, selectedWorkflow, selectedWorkflowTasks]);
|
||||||
|
|
||||||
// Card-placed field defs grouped by workflow id (U13/KTD-14). Only recomputes
|
// Card-placed field defs grouped by workflow id (U13/KTD-14). Only recomputes
|
||||||
// when the board-workflows payload changes, not on every SSE task tick.
|
// when the board-workflows payload changes, not on every SSE task tick.
|
||||||
@@ -535,6 +549,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
>
|
>
|
||||||
{selectedWorkflowColumns.map((columnDef) => {
|
{selectedWorkflowColumns.map((columnDef) => {
|
||||||
const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId;
|
const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId;
|
||||||
|
const isWorkflowDoneLikeColumn = columnDef.flags.complete === true && columnDef.flags.archived !== true;
|
||||||
return (
|
return (
|
||||||
<Column
|
<Column
|
||||||
key={columnDef.id}
|
key={columnDef.id}
|
||||||
@@ -579,6 +594,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
{...(isCreateColumn ? { onQuickCreate: handleWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
{...(isCreateColumn ? { onQuickCreate: handleWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||||
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
||||||
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
|
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
|
||||||
|
{...(isWorkflowDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -671,7 +687,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
autoMerge={autoMerge}
|
autoMerge={autoMerge}
|
||||||
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||||
{...(col === "in-review" ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
{...(col === "in-review" ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
||||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
{...(col === "done" ? { onArchiveAllDone, doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
||||||
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
|
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { ToastType } from "../hooks/useToast";
|
|||||||
import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react";
|
import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react";
|
||||||
import type { ModelInfo, BoardWorkflowColumnFlags } from "../api";
|
import type { ModelInfo, BoardWorkflowColumnFlags } from "../api";
|
||||||
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
||||||
|
import type { DoneColumnSortMode } from "./taskSorting";
|
||||||
|
|
||||||
const PAGINATED_COLUMN_THRESHOLD = 100;
|
const PAGINATED_COLUMN_THRESHOLD = 100;
|
||||||
const VISIBLE_TASKS_INITIAL = 50;
|
const VISIBLE_TASKS_INITIAL = 50;
|
||||||
@@ -115,6 +116,10 @@ interface ColumnProps {
|
|||||||
githubIssueAction?: GithubIssueAction;
|
githubIssueAction?: GithubIssueAction;
|
||||||
}) => Promise<Task>;
|
}) => Promise<Task>;
|
||||||
onArchiveAllDone?: () => Promise<Task[]>;
|
onArchiveAllDone?: () => Promise<Task[]>;
|
||||||
|
/** Current Done-column display order, supplied only for the board's Done surface. */
|
||||||
|
doneSortMode?: DoneColumnSortMode;
|
||||||
|
/** Updates the board-local Done-column display order. */
|
||||||
|
onDoneSortModeChange?: (mode: DoneColumnSortMode) => void;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
onToggleCollapse?: () => void;
|
onToggleCollapse?: () => void;
|
||||||
allTasks?: Task[];
|
allTasks?: Task[];
|
||||||
@@ -172,7 +177,7 @@ interface ColumnProps {
|
|||||||
getDraggingTaskId?: () => string | null;
|
getDraggingTaskId?: () => string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
|
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
|
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
|
||||||
// scopes `t` to the useTranslation binding, so the shared translateRejection
|
// scopes `t` to the useTranslation binding, so the shared translateRejection
|
||||||
@@ -535,6 +540,14 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
|||||||
}
|
}
|
||||||
}, [tasks, columnLabelText, onMoveTask, addToast, confirm, t]);
|
}, [tasks, columnLabelText, onMoveTask, addToast, confirm, t]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:DoneColumnSorting 2026-06-29-20:23:
|
||||||
|
In workflow mode, the Done-sort control belongs to non-archived complete lanes even when the workflow uses a custom column id such as `shipped`; legacy mode remains limited to the literal Done column.
|
||||||
|
*/
|
||||||
|
const isDoneSortColumn = workflowMode ? columnFlags?.complete === true && columnFlags?.archived !== true : column === "done";
|
||||||
|
const showDoneSortControl = isDoneSortColumn && doneSortMode !== undefined && !!onDoneSortModeChange;
|
||||||
|
const doneSortControlLabel = t("column.doneSortControlLabel", "Sort Done tasks");
|
||||||
|
|
||||||
const handleArchiveAll = useCallback(async () => {
|
const handleArchiveAll = useCallback(async () => {
|
||||||
if (!onArchiveAllDone) return;
|
if (!onArchiveAllDone) return;
|
||||||
if (tasks.length === 0) return;
|
if (tasks.length === 0) return;
|
||||||
@@ -582,6 +595,24 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
|||||||
+ {t("column.newTask", "New Task")}
|
+ {t("column.newTask", "New Task")}
|
||||||
</button>
|
</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 && (
|
{column === "done" && onArchiveAllDone && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-icon btn-sm"
|
className="btn btn-icon btn-sm"
|
||||||
|
|||||||
@@ -50,11 +50,44 @@ const columnRenderCounts: Record<string, number> = {};
|
|||||||
|
|
||||||
// Mock child components so we only test Board's own rendering
|
// Mock child components so we only test Board's own rendering
|
||||||
vi.mock("../Column", () => ({
|
vi.mock("../Column", () => ({
|
||||||
Column: React.memo(({ column, tasks, collapsed, onToggleCollapse, onQuickCreate, onNewTask, onToggleAutoMerge, onArchiveAllDone, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive }: { 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 }) => {
|
Column: React.memo(({
|
||||||
|
column,
|
||||||
|
tasks,
|
||||||
|
collapsed,
|
||||||
|
onToggleCollapse,
|
||||||
|
onQuickCreate,
|
||||||
|
onNewTask,
|
||||||
|
onToggleAutoMerge,
|
||||||
|
onArchiveAllDone,
|
||||||
|
favoriteProviders,
|
||||||
|
favoriteModels,
|
||||||
|
onToggleFavorite,
|
||||||
|
onToggleModelFavorite,
|
||||||
|
isSearchActive,
|
||||||
|
doneSortMode,
|
||||||
|
onDoneSortModeChange,
|
||||||
|
}: {
|
||||||
|
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;
|
||||||
|
doneSortMode?: string;
|
||||||
|
onDoneSortModeChange?: (mode: "completion-date-desc" | "task-id-desc") => void;
|
||||||
|
}) => {
|
||||||
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
|
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
|
||||||
return (
|
return (
|
||||||
<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"}>
|
<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-done-sort-mode={doneSortMode ?? ""} data-has-done-sort-handler={onDoneSortModeChange ? "yes" : "no"}>
|
||||||
{onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>}
|
{onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>}
|
||||||
|
{onDoneSortModeChange && <button type="button" onClick={() => onDoneSortModeChange("task-id-desc")}>sort-{column}-by-id</button>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -497,6 +530,40 @@ describe("Board", () => {
|
|||||||
expect(doneTasks.map((t: Task) => t.id)).toEqual(["FN-012", "FN-011", "FN-010"]);
|
expect(doneTasks.map((t: Task) => t.id)).toEqual(["FN-012", "FN-011", "FN-010"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("threads Done sort state through the legacy board without altering other columns", () => {
|
||||||
|
const tasks: Task[] = [
|
||||||
|
createTask({ id: "FN-003", description: "Old done", column: "done", columnMovedAt: "2024-01-01T09:00:00.000Z" }),
|
||||||
|
createTask({ id: "FN-001", description: "New done", column: "done", columnMovedAt: "2024-01-01T11:00:00.000Z" }),
|
||||||
|
createTask({ id: "FN-002", description: "Tie low id", column: "done", columnMovedAt: "2024-01-01T10:00:00.000Z" }),
|
||||||
|
createTask({ id: "FN-004", description: "Tie high id", column: "done", columnMovedAt: "2024-01-01T10:00:00.000Z" }),
|
||||||
|
createTask({ id: "FN-050", description: "Todo fifty", column: "todo", priority: "normal", createdAt: "2024-01-01T10:00:00.000Z" }),
|
||||||
|
createTask({ id: "FN-010", description: "Todo ten", column: "todo", priority: "normal", createdAt: "2024-01-01T10:00:00.000Z" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderBoard({ tasks });
|
||||||
|
|
||||||
|
const readIds = (column: string) => (JSON.parse(screen.getByTestId(`column-${column}`).getAttribute("data-tasks") || "[]") as Task[]).map((task) => task.id);
|
||||||
|
expect(screen.getByTestId("column-done")).toHaveAttribute("data-done-sort-mode", "completion-date-desc");
|
||||||
|
expect(screen.getByTestId("column-done")).toHaveAttribute("data-has-done-sort-handler", "yes");
|
||||||
|
expect(readIds("done")).toEqual(["FN-001", "FN-002", "FN-004", "FN-003"]);
|
||||||
|
expect(readIds("todo")).toEqual(["FN-010", "FN-050"]);
|
||||||
|
expect(screen.getByTestId("column-todo")).toHaveAttribute("data-has-done-sort-handler", "no");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "sort-done-by-id" }));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("column-done")).toHaveAttribute("data-done-sort-mode", "task-id-desc");
|
||||||
|
expect(readIds("done")).toEqual(["FN-004", "FN-003", "FN-002", "FN-001"]);
|
||||||
|
expect(readIds("todo")).toEqual(["FN-010", "FN-050"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes Done sort state to an empty legacy Done column", () => {
|
||||||
|
renderBoard({ tasks: [] });
|
||||||
|
|
||||||
|
expect(screen.getByTestId("column-done")).toHaveAttribute("data-tasks", "[]");
|
||||||
|
expect(screen.getByTestId("column-done")).toHaveAttribute("data-done-sort-mode", "completion-date-desc");
|
||||||
|
expect(screen.getByTestId("column-done")).toHaveAttribute("data-has-done-sort-handler", "yes");
|
||||||
|
});
|
||||||
|
|
||||||
it("orders todo by priority before age", () => {
|
it("orders todo by priority before age", () => {
|
||||||
const tasks: Task[] = [
|
const tasks: Task[] = [
|
||||||
createTask({
|
createTask({
|
||||||
@@ -1322,6 +1389,68 @@ describe("Board", () => {
|
|||||||
expect(screen.queryByTestId("column-archived")).toBeNull();
|
expect(screen.queryByTestId("column-archived")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("built-in workflow Done uses the selected Done sort mode", async () => {
|
||||||
|
const tasks = [
|
||||||
|
mkTask({ id: "FN-003", column: "done", columnMovedAt: "2024-01-01T09:00:00.000Z" }),
|
||||||
|
mkTask({ id: "FN-001", column: "done", columnMovedAt: "2024-01-01T11:00:00.000Z" }),
|
||||||
|
mkTask({ id: "FN-002", column: "done", columnMovedAt: "2024-01-01T10:00:00.000Z" }),
|
||||||
|
mkTask({ id: "FN-004", column: "done", columnMovedAt: "2024-01-01T10:00:00.000Z" }),
|
||||||
|
mkTask({ id: "FN-050", column: "todo", priority: "normal", createdAt: "2024-01-01T10:00:00.000Z" }),
|
||||||
|
mkTask({ id: "FN-010", column: "todo", priority: "normal", createdAt: "2024-01-01T10:00:00.000Z" }),
|
||||||
|
];
|
||||||
|
enableFlag(Object.fromEntries(tasks.map((task) => [task.id, "builtin:coding"])));
|
||||||
|
renderBoard({ tasks });
|
||||||
|
|
||||||
|
const readIds = (column: string) => (JSON.parse(screen.getByTestId(`column-${column}`).getAttribute("data-tasks") || "[]") as Task[]).map((task) => task.id);
|
||||||
|
await waitFor(() => expect(screen.getByTestId("column-done")).toHaveAttribute("data-done-sort-mode", "completion-date-desc"));
|
||||||
|
expect(readIds("done")).toEqual(["FN-001", "FN-002", "FN-004", "FN-003"]);
|
||||||
|
expect(readIds("todo")).toEqual(["FN-010", "FN-050"]);
|
||||||
|
expect(screen.getByTestId("column-todo")).toHaveAttribute("data-has-done-sort-handler", "no");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "sort-done-by-id" }));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("column-done")).toHaveAttribute("data-done-sort-mode", "task-id-desc");
|
||||||
|
expect(readIds("done")).toEqual(["FN-004", "FN-003", "FN-002", "FN-001"]);
|
||||||
|
expect(readIds("todo")).toEqual(["FN-010", "FN-050"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes Done sort state to an empty built-in workflow Done column", async () => {
|
||||||
|
enableFlag({});
|
||||||
|
renderBoard({ tasks: [] });
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId("column-done")).toHaveAttribute("data-tasks", "[]"));
|
||||||
|
expect(screen.getByTestId("column-done")).toHaveAttribute("data-done-sort-mode", "completion-date-desc");
|
||||||
|
expect(screen.getByTestId("column-done")).toHaveAttribute("data-has-done-sort-handler", "yes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the selected Done sort mode for custom complete workflow columns", async () => {
|
||||||
|
const workflow = {
|
||||||
|
id: "wf-shipped",
|
||||||
|
name: "Custom shipped",
|
||||||
|
columns: [
|
||||||
|
{ id: "todo", name: "Todo", flags: { intake: true } },
|
||||||
|
{ id: "shipped", name: "Shipped", flags: { complete: true } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const tasks = [
|
||||||
|
mkTask({ id: "FN-003", column: "shipped", priority: "normal", columnMovedAt: "2024-01-01T09:00:00.000Z" }),
|
||||||
|
mkTask({ id: "FN-001", column: "shipped", priority: "normal", columnMovedAt: "2024-01-01T11:00:00.000Z" }),
|
||||||
|
mkTask({ id: "FN-002", column: "shipped", priority: "normal", columnMovedAt: "2024-01-01T10:00:00.000Z" }),
|
||||||
|
];
|
||||||
|
enableFlag({ "FN-003": workflow.id, "FN-001": workflow.id, "FN-002": workflow.id }, [workflow]);
|
||||||
|
renderBoard({ tasks });
|
||||||
|
|
||||||
|
const readIds = () => (JSON.parse(screen.getByTestId("column-shipped").getAttribute("data-tasks") || "[]") as Task[]).map((task) => task.id);
|
||||||
|
await waitFor(() => expect(screen.getByTestId("column-shipped")).toHaveAttribute("data-done-sort-mode", "completion-date-desc"));
|
||||||
|
expect(screen.getByTestId("column-shipped")).toHaveAttribute("data-has-done-sort-handler", "yes");
|
||||||
|
expect(readIds()).toEqual(["FN-001", "FN-002", "FN-003"]);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "sort-shipped-by-id" }));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("column-shipped")).toHaveAttribute("data-done-sort-mode", "task-id-desc");
|
||||||
|
expect(readIds()).toEqual(["FN-003", "FN-002", "FN-001"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("done column in workflow mode receives onArchiveAllDone prop", async () => {
|
it("done column in workflow mode receives onArchiveAllDone prop", async () => {
|
||||||
const onArchiveAllDone = vi.fn();
|
const onArchiveAllDone = vi.fn();
|
||||||
enableFlag({ "FN-1": "builtin:coding" });
|
enableFlag({ "FN-1": "builtin:coding" });
|
||||||
|
|||||||
@@ -651,6 +651,164 @@ 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(
|
||||||
|
<Column
|
||||||
|
{...defaultProps}
|
||||||
|
column="done"
|
||||||
|
tasks={[{ ...makeTask("FN-001"), column: "done" }]}
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the selector for workflow complete columns with custom ids", () => {
|
||||||
|
render(
|
||||||
|
<Column
|
||||||
|
{...defaultProps}
|
||||||
|
column={"shipped" as ColumnType}
|
||||||
|
workflowMode
|
||||||
|
columnDisplayName="Shipped"
|
||||||
|
columnFlags={{ complete: true }}
|
||||||
|
tasks={[{ ...makeTask("FN-001"), column: "shipped" as ColumnType }]}
|
||||||
|
doneSortMode="completion-date-desc"
|
||||||
|
onDoneSortModeChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("heading", { name: "Shipped" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("combobox", { name: "Sort Done tasks" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects task ID descending from the Done header", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onDoneSortModeChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<Column
|
||||||
|
{...defaultProps}
|
||||||
|
column="done"
|
||||||
|
tasks={[{ ...makeTask("FN-001"), column: "done" }]}
|
||||||
|
doneSortMode="completion-date-desc"
|
||||||
|
onDoneSortModeChange={onDoneSortModeChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.selectOptions(screen.getByRole("combobox", { name: "Sort Done tasks" }), "task-id-desc");
|
||||||
|
|
||||||
|
expect(onDoneSortModeChange).toHaveBeenCalledWith("task-id-desc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects completion-date descending from the Done header", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onDoneSortModeChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<Column
|
||||||
|
{...defaultProps}
|
||||||
|
column="done"
|
||||||
|
tasks={[{ ...makeTask("FN-002"), column: "done" }]}
|
||||||
|
doneSortMode="task-id-desc"
|
||||||
|
onDoneSortModeChange={onDoneSortModeChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.selectOptions(screen.getByRole("combobox", { name: "Sort Done tasks" }), "completion-date-desc");
|
||||||
|
|
||||||
|
expect(onDoneSortModeChange).toHaveBeenCalledWith("completion-date-desc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the Done sort selector available when Done is empty", () => {
|
||||||
|
render(
|
||||||
|
<Column
|
||||||
|
{...defaultProps}
|
||||||
|
column="done"
|
||||||
|
tasks={[]}
|
||||||
|
doneSortMode="completion-date-desc"
|
||||||
|
onDoneSortModeChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("combobox", { name: "Sort Done tasks" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("0")).toHaveClass("column-count");
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Column
|
||||||
|
{...defaultProps}
|
||||||
|
column="todo"
|
||||||
|
tasks={[{ ...makeTask("FN-001"), column: "todo" }]}
|
||||||
|
doneSortMode="completion-date-desc"
|
||||||
|
onDoneSortModeChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the sort control on Done when sort props are absent", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Column
|
||||||
|
{...defaultProps}
|
||||||
|
column="done"
|
||||||
|
tasks={[{ ...makeTask("FN-001"), column: "done" }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("combobox", { name: "Sort Done tasks" })).toBeNull();
|
||||||
|
expect(container.querySelector(".done-sort-control")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Column same-column drop", () => {
|
describe("Column same-column drop", () => {
|
||||||
it("does not call onMoveTask when dropping task into its current column", () => {
|
it("does not call onMoveTask when dropping task into its current column", () => {
|
||||||
const onMoveTask = vi.fn().mockResolvedValue({} as Task);
|
const onMoveTask = vi.fn().mockResolvedValue({} as Task);
|
||||||
|
|||||||
114
packages/dashboard/app/components/__tests__/taskSorting.test.ts
Normal file
114
packages/dashboard/app/components/__tests__/taskSorting.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Task } from "@fusion/core";
|
||||||
|
import { sortTasksForDisplayColumn } from "../taskSorting";
|
||||||
|
|
||||||
|
function task(overrides: Partial<Task> & { id: string }): Task {
|
||||||
|
return {
|
||||||
|
id: overrides.id,
|
||||||
|
title: overrides.title ?? overrides.id,
|
||||||
|
description: overrides.description ?? "",
|
||||||
|
column: overrides.column ?? "done",
|
||||||
|
status: overrides.status ?? "idle",
|
||||||
|
priority: overrides.priority ?? "normal",
|
||||||
|
createdAt: overrides.createdAt ?? "2026-06-01T00:00:00.000Z",
|
||||||
|
updatedAt: overrides.updatedAt ?? "2026-06-01T00:00:00.000Z",
|
||||||
|
columnMovedAt: overrides.columnMovedAt,
|
||||||
|
dependencies: overrides.dependencies ?? [],
|
||||||
|
...overrides,
|
||||||
|
} as Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ids(tasks: Task[]): string[] {
|
||||||
|
return tasks.map((entry) => entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sortTasksForDisplayColumn", () => {
|
||||||
|
it("keeps the shared helper safe for empty done arrays", () => {
|
||||||
|
expect(sortTasksForDisplayColumn([], "done")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults Done to completion-date descending with numeric task-id ascending ties", () => {
|
||||||
|
const tasks = [
|
||||||
|
task({ id: "FN-7240", columnMovedAt: "2026-06-01T00:00:00.000Z" }),
|
||||||
|
task({ id: "FN-7239", columnMovedAt: "2026-06-03T00:00:00.000Z" }),
|
||||||
|
task({ id: "FN-7238", columnMovedAt: "2026-06-03T00:00:00.000Z" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(ids(sortTasksForDisplayColumn(tasks, "done"))).toEqual(["FN-7238", "FN-7239", "FN-7240"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the default when Done completion-date descending is explicit", () => {
|
||||||
|
const tasks = [
|
||||||
|
task({ id: "FN-7238", columnMovedAt: "2026-06-01T00:00:00.000Z" }),
|
||||||
|
task({ id: "FN-7239", columnMovedAt: "2026-06-02T00:00:00.000Z" }),
|
||||||
|
task({ id: "FN-7240", columnMovedAt: "2026-06-03T00:00:00.000Z" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(ids(sortTasksForDisplayColumn(tasks, "done", "completion-date-desc"))).toEqual([
|
||||||
|
"FN-7240",
|
||||||
|
"FN-7239",
|
||||||
|
"FN-7238",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts Done by numeric task id descending when requested", () => {
|
||||||
|
const tasks = [
|
||||||
|
task({ id: "FN-7239", columnMovedAt: "2026-06-03T00:00:00.000Z" }),
|
||||||
|
task({ id: "FN-7240", columnMovedAt: "2026-06-01T00:00:00.000Z" }),
|
||||||
|
task({ id: "FN-7238", columnMovedAt: "2026-06-04T00:00:00.000Z" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(ids(sortTasksForDisplayColumn(tasks, "done", "task-id-desc"))).toEqual([
|
||||||
|
"FN-7240",
|
||||||
|
"FN-7239",
|
||||||
|
"FN-7238",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses lexical descending fallback for non-numeric Done task ids", () => {
|
||||||
|
const tasks = [
|
||||||
|
task({ id: "TASK-alpha" }),
|
||||||
|
task({ id: "TASK-charlie" }),
|
||||||
|
task({ id: "TASK-bravo" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(ids(sortTasksForDisplayColumn(tasks, "done", "task-id-desc"))).toEqual([
|
||||||
|
"TASK-charlie",
|
||||||
|
"TASK-bravo",
|
||||||
|
"TASK-alpha",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back from missing or invalid Done dates to deterministic task-id ties", () => {
|
||||||
|
const tasks = [
|
||||||
|
task({ id: "FN-7240", createdAt: "not-a-date", updatedAt: undefined, columnMovedAt: undefined }),
|
||||||
|
task({ id: "FN-7238", createdAt: "also-not-a-date", updatedAt: undefined, columnMovedAt: undefined }),
|
||||||
|
task({
|
||||||
|
id: "FN-7239",
|
||||||
|
createdAt: "2026-06-02T00:00:00.000Z",
|
||||||
|
updatedAt: undefined,
|
||||||
|
columnMovedAt: undefined,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(ids(sortTasksForDisplayColumn(tasks, "done", "completion-date-desc"))).toEqual([
|
||||||
|
"FN-7239",
|
||||||
|
"FN-7238",
|
||||||
|
"FN-7240",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply the Done sort mode to other display columns", () => {
|
||||||
|
const tasks = [
|
||||||
|
task({ id: "FN-7240", column: "todo", priority: "low", createdAt: "2026-06-03T00:00:00.000Z" }),
|
||||||
|
task({ id: "FN-7238", column: "todo", priority: "urgent", createdAt: "2026-06-02T00:00:00.000Z" }),
|
||||||
|
task({ id: "FN-7239", column: "todo", priority: "normal", createdAt: "2026-06-01T00:00:00.000Z" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(ids(sortTasksForDisplayColumn(tasks, "todo", "task-id-desc"))).toEqual([
|
||||||
|
"FN-7238",
|
||||||
|
"FN-7239",
|
||||||
|
"FN-7240",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Task, Column } from "@fusion/core";
|
import type { Task, Column } from "@fusion/core";
|
||||||
|
|
||||||
|
export type DoneColumnSortMode = "completion-date-desc" | "task-id-desc";
|
||||||
|
|
||||||
function getTaskPriorityRank(priority: Task["priority"] | null | undefined): number {
|
function getTaskPriorityRank(priority: Task["priority"] | null | undefined): number {
|
||||||
if (priority === "urgent") return 3;
|
if (priority === "urgent") return 3;
|
||||||
if (priority === "high") return 2;
|
if (priority === "high") return 2;
|
||||||
@@ -11,17 +13,35 @@ function compareTaskPriority(a: Task["priority"] | null | undefined, b: Task["pr
|
|||||||
return getTaskPriorityRank(b) - getTaskPriorityRank(a);
|
return getTaskPriorityRank(b) - getTaskPriorityRank(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareTaskIdNumeric(a: string, b: string): number {
|
function getTaskIdNumericToken(id: string): number | null {
|
||||||
const aNum = Number.parseInt(a.slice(a.lastIndexOf("-") + 1), 10);
|
const token = id.slice(id.lastIndexOf("-") + 1);
|
||||||
const bNum = Number.parseInt(b.slice(b.lastIndexOf("-") + 1), 10);
|
if (!/^\d+$/.test(token)) return null;
|
||||||
|
const parsed = Number.parseInt(token, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
if (Number.isFinite(aNum) && Number.isFinite(bNum) && aNum !== bNum) {
|
function compareTaskIdNumeric(a: string, b: string): number {
|
||||||
|
const aNum = getTaskIdNumericToken(a);
|
||||||
|
const bNum = getTaskIdNumericToken(b);
|
||||||
|
|
||||||
|
if (aNum !== null && bNum !== null && aNum !== bNum) {
|
||||||
return aNum - bNum;
|
return aNum - bNum;
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.localeCompare(b);
|
return a.localeCompare(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareTaskIdNumericDesc(a: string, b: string): number {
|
||||||
|
const aNum = getTaskIdNumericToken(a);
|
||||||
|
const bNum = getTaskIdNumericToken(b);
|
||||||
|
|
||||||
|
if (aNum !== null && bNum !== null && aNum !== bNum) {
|
||||||
|
return bNum - aNum;
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.localeCompare(a);
|
||||||
|
}
|
||||||
|
|
||||||
function getDoneSortTimestamp(task: Task): number {
|
function getDoneSortTimestamp(task: Task): number {
|
||||||
const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt;
|
const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt;
|
||||||
const parsed = Date.parse(timestamp);
|
const parsed = Date.parse(timestamp);
|
||||||
@@ -32,7 +52,11 @@ function isMergeActiveStatus(status: string | null | undefined): boolean {
|
|||||||
return status === "merging" || status === "merging-pr" || status === "merging-fix";
|
return status === "merging" || status === "merging-pr" || status === "merging-fix";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortTasksForDisplayColumn(tasks: readonly Task[], column: Column): Task[] {
|
export function sortTasksForDisplayColumn(
|
||||||
|
tasks: readonly Task[],
|
||||||
|
column: Column,
|
||||||
|
doneSortMode: DoneColumnSortMode = "completion-date-desc",
|
||||||
|
): Task[] {
|
||||||
if (column === "todo") {
|
if (column === "todo") {
|
||||||
return [...tasks].sort((a, b) => {
|
return [...tasks].sort((a, b) => {
|
||||||
const priorityCmp = compareTaskPriority(a.priority, b.priority);
|
const priorityCmp = compareTaskPriority(a.priority, b.priority);
|
||||||
@@ -44,6 +68,13 @@ export function sortTasksForDisplayColumn(tasks: readonly Task[], column: Column
|
|||||||
|
|
||||||
return [...tasks].sort((a, b) => {
|
return [...tasks].sort((a, b) => {
|
||||||
if (column === "done") {
|
if (column === "done") {
|
||||||
|
/*
|
||||||
|
FNXC:DoneColumnSorting 2026-06-29-14:48:
|
||||||
|
Done keeps completion-date descending as the default for existing board, lane, and list callers while supporting an explicit task-id descending mode for users who need newest FN ids first.
|
||||||
|
*/
|
||||||
|
if (doneSortMode === "task-id-desc") {
|
||||||
|
return compareTaskIdNumericDesc(a.id, b.id);
|
||||||
|
}
|
||||||
const timestampCmp = getDoneSortTimestamp(b) - getDoneSortTimestamp(a);
|
const timestampCmp = getDoneSortTimestamp(b) - getDoneSortTimestamp(a);
|
||||||
if (timestampCmp !== 0) return timestampCmp;
|
if (timestampCmp !== 0) return timestampCmp;
|
||||||
return compareTaskIdNumeric(a.id, b.id);
|
return compareTaskIdNumeric(a.id, b.id);
|
||||||
|
|||||||
@@ -1111,6 +1111,7 @@ body {
|
|||||||
.column-header {
|
.column-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
padding: calc(var(--space-lg) - 2px) calc(var(--space-lg) - 2px) 0;
|
padding: calc(var(--space-lg) - 2px) calc(var(--space-lg) - 2px) 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1173,6 +1174,40 @@ body {
|
|||||||
animation: count-flash-bg 1400ms ease-out;
|
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 {
|
.column-desc {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
|
|||||||
Reference in New Issue
Block a user