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
|
||||
- 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
|
||||
- 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.
|
||||
<!-- 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`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } 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 "./Lane.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) {
|
||||
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 boardRef = useRef<HTMLElement | null>(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[]>;
|
||||
|
||||
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)
|
||||
? previousGrouped[column]
|
||||
: sortedTasks;
|
||||
@@ -231,7 +238,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
|
||||
tasksByColumnCacheRef.current = stableGrouped;
|
||||
return stableGrouped;
|
||||
}, [tasks]);
|
||||
}, [tasks, doneSortMode]);
|
||||
|
||||
// FN-4574 + FN-001 diagnosis: on iOS Safari, the mobile board can occasionally
|
||||
// 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);
|
||||
}
|
||||
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;
|
||||
}, [selectedWorkflow, selectedWorkflowTasks]);
|
||||
}, [doneSortMode, selectedWorkflow, selectedWorkflowTasks]);
|
||||
|
||||
// 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.
|
||||
@@ -535,6 +549,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
>
|
||||
{selectedWorkflowColumns.map((columnDef) => {
|
||||
const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId;
|
||||
const isWorkflowDoneLikeColumn = columnDef.flags.complete === true && columnDef.flags.archived !== true;
|
||||
return (
|
||||
<Column
|
||||
key={columnDef.id}
|
||||
@@ -579,6 +594,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
{...(isCreateColumn ? { onQuickCreate: handleWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
||||
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
|
||||
{...(isWorkflowDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -671,7 +687,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
autoMerge={autoMerge}
|
||||
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||
{...(col === "in-review" ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||
{...(col === "done" ? { onArchiveAllDone, doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
||||
{...(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 type { ModelInfo, BoardWorkflowColumnFlags } from "../api";
|
||||
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
||||
import type { DoneColumnSortMode } from "./taskSorting";
|
||||
|
||||
const PAGINATED_COLUMN_THRESHOLD = 100;
|
||||
const VISIBLE_TASKS_INITIAL = 50;
|
||||
@@ -115,6 +116,10 @@ interface ColumnProps {
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
}) => 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;
|
||||
onToggleCollapse?: () => void;
|
||||
allTasks?: Task[];
|
||||
@@ -172,7 +177,7 @@ interface ColumnProps {
|
||||
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");
|
||||
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
|
||||
// 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]);
|
||||
|
||||
/*
|
||||
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 () => {
|
||||
if (!onArchiveAllDone) return;
|
||||
if (tasks.length === 0) return;
|
||||
@@ -582,6 +595,24 @@ 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"
|
||||
|
||||
@@ -50,11 +50,44 @@ const columnRenderCounts: Record<string, number> = {};
|
||||
|
||||
// Mock child components so we only test Board's own rendering
|
||||
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;
|
||||
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>}
|
||||
{onDoneSortModeChange && <button type="button" onClick={() => onDoneSortModeChange("task-id-desc")}>sort-{column}-by-id</button>}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
@@ -497,6 +530,40 @@ describe("Board", () => {
|
||||
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", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({
|
||||
@@ -1322,6 +1389,68 @@ describe("Board", () => {
|
||||
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 () => {
|
||||
const onArchiveAllDone = vi.fn();
|
||||
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", () => {
|
||||
it("does not call onMoveTask when dropping task into its current column", () => {
|
||||
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";
|
||||
|
||||
export type DoneColumnSortMode = "completion-date-desc" | "task-id-desc";
|
||||
|
||||
function getTaskPriorityRank(priority: Task["priority"] | null | undefined): number {
|
||||
if (priority === "urgent") return 3;
|
||||
if (priority === "high") return 2;
|
||||
@@ -11,17 +13,35 @@ function compareTaskPriority(a: Task["priority"] | null | undefined, b: Task["pr
|
||||
return getTaskPriorityRank(b) - getTaskPriorityRank(a);
|
||||
}
|
||||
|
||||
function compareTaskIdNumeric(a: string, b: string): number {
|
||||
const aNum = Number.parseInt(a.slice(a.lastIndexOf("-") + 1), 10);
|
||||
const bNum = Number.parseInt(b.slice(b.lastIndexOf("-") + 1), 10);
|
||||
function getTaskIdNumericToken(id: string): number | null {
|
||||
const token = id.slice(id.lastIndexOf("-") + 1);
|
||||
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 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 {
|
||||
const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt;
|
||||
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";
|
||||
}
|
||||
|
||||
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") {
|
||||
return [...tasks].sort((a, b) => {
|
||||
const priorityCmp = compareTaskPriority(a.priority, b.priority);
|
||||
@@ -44,6 +68,13 @@ export function sortTasksForDisplayColumn(tasks: readonly Task[], column: Column
|
||||
|
||||
return [...tasks].sort((a, b) => {
|
||||
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);
|
||||
if (timestampCmp !== 0) return timestampCmp;
|
||||
return compareTaskIdNumeric(a.id, b.id);
|
||||
|
||||
@@ -1111,6 +1111,7 @@ body {
|
||||
.column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
padding: calc(var(--space-lg) - 2px) calc(var(--space-lg) - 2px) 0;
|
||||
min-width: 0;
|
||||
@@ -1173,6 +1174,40 @@ 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);
|
||||
|
||||
Reference in New Issue
Block a user