feat(FN-3257): sort board tasks by priority then task number instead of mov
Replaced the board's task ordering from time-based (`columnMovedAt`/`createdAt`) to a priority-then-task-number sort, inlining previously external priority helpers to fix Vite bundling. Tests were updated in `Board.test.tsx` to match the new sort semantics and `ChatView.test.tsx` keyboard tests were Fusion-Task-Id: FN-3257
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput } from "@fusion/core";
|
||||
import { COLUMNS } from "@fusion/core";
|
||||
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, TaskPriority } from "@fusion/core";
|
||||
import { COLUMNS, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY } from "@fusion/core";
|
||||
import { Column } from "./Column";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
@@ -53,8 +53,38 @@ interface BoardProps {
|
||||
lastFetchTimeMs?: number;
|
||||
}
|
||||
|
||||
const PRIORITY_RANK: Record<TaskPriority, number> = {
|
||||
low: 0,
|
||||
normal: 1,
|
||||
high: 2,
|
||||
urgent: 3,
|
||||
};
|
||||
|
||||
function normalizePriority(priority: unknown): TaskPriority {
|
||||
if (typeof priority === "string" && (TASK_PRIORITIES as readonly string[]).includes(priority)) {
|
||||
return priority as TaskPriority;
|
||||
}
|
||||
return DEFAULT_TASK_PRIORITY;
|
||||
}
|
||||
|
||||
function compareTaskPriority(a: unknown, b: unknown): number {
|
||||
return PRIORITY_RANK[normalizePriority(b)] - PRIORITY_RANK[normalizePriority(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);
|
||||
|
||||
if (Number.isFinite(aNum) && Number.isFinite(bNum) && aNum !== bNum) {
|
||||
return aNum - bNum;
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function sortTasksForColumn(tasks: Task[], column: ColumnType): Task[] {
|
||||
return [...tasks].sort((a, b) => {
|
||||
// In the in-review column, merging tasks stay pinned above non-merging tasks.
|
||||
if (column === "in-review") {
|
||||
const aIsMerging = a.status === "merging" || a.status === "merging-pr";
|
||||
const bIsMerging = b.status === "merging" || b.status === "merging-pr";
|
||||
@@ -63,12 +93,15 @@ function sortTasksForColumn(tasks: Task[], column: ColumnType): Task[] {
|
||||
}
|
||||
}
|
||||
|
||||
if (a.columnMovedAt && b.columnMovedAt) {
|
||||
return b.columnMovedAt.localeCompare(a.columnMovedAt);
|
||||
// Primary sort: priority descending (urgent → high → normal → low).
|
||||
// compareTaskPriority normalizes missing/invalid values to `normal`.
|
||||
const priorityCmp = compareTaskPriority(a.priority, b.priority);
|
||||
if (priorityCmp !== 0) {
|
||||
return priorityCmp;
|
||||
}
|
||||
if (a.columnMovedAt && !b.columnMovedAt) return -1;
|
||||
if (!a.columnMovedAt && b.columnMovedAt) return 1;
|
||||
return a.createdAt.localeCompare(b.createdAt);
|
||||
|
||||
// Secondary sort: numeric task ID ascending (lower number first).
|
||||
return compareTaskIdNumeric(a.id, b.id);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -291,44 +291,65 @@ describe("Board", () => {
|
||||
expect(columnRenderCounts.done).toBeGreaterThanOrEqual(initialDoneRenders);
|
||||
});
|
||||
|
||||
it("filtered tasks are sorted correctly (columnMovedAt, createdAt)", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({
|
||||
id: "FN-001",
|
||||
description: "Old task with move time",
|
||||
column: "todo",
|
||||
columnMovedAt: "2024-01-01T10:00:00.000Z",
|
||||
createdAt: "2024-01-01T08:00:00.000Z",
|
||||
}),
|
||||
createTask({
|
||||
id: "FN-002",
|
||||
description: "Newer task with move time",
|
||||
column: "todo",
|
||||
columnMovedAt: "2024-01-01T12:00:00.000Z",
|
||||
createdAt: "2024-01-01T08:00:00.000Z",
|
||||
}),
|
||||
createTask({
|
||||
id: "FN-003",
|
||||
description: "Legacy task no move time",
|
||||
column: "todo",
|
||||
createdAt: "2024-01-01T09:00:00.000Z",
|
||||
}),
|
||||
];
|
||||
describe("sortTasksForColumn priority ordering", () => {
|
||||
it("orders tasks by priority descending (urgent → high → normal → low)", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-003", description: "Low task", column: "todo", priority: "low" }),
|
||||
createTask({ id: "FN-001", description: "Urgent task", column: "todo", priority: "urgent" }),
|
||||
createTask({ id: "FN-004", description: "Normal task", column: "todo", priority: "normal" }),
|
||||
createTask({ id: "FN-002", description: "High task", column: "todo", priority: "high" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks, searchQuery: "task" });
|
||||
renderBoard({ tasks, searchQuery: "task" });
|
||||
|
||||
const todoColumn = screen.getByTestId("column-todo");
|
||||
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]") as Task[];
|
||||
const todoTasks = JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]") as Task[];
|
||||
expect(todoTasks).toHaveLength(4);
|
||||
expect(todoTasks.map((t: Task) => t.id)).toEqual(["FN-001", "FN-002", "FN-004", "FN-003"]);
|
||||
});
|
||||
|
||||
// Should have all 3 tasks
|
||||
expect(todoTasks).toHaveLength(3);
|
||||
it("orders same-priority tasks by numeric task ID ascending", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-050", description: "Fifty", column: "in-progress", priority: "normal" }),
|
||||
createTask({ id: "FN-010", description: "Ten", column: "in-progress", priority: "normal" }),
|
||||
createTask({ id: "FN-030", description: "Thirty", column: "in-progress", priority: "normal" }),
|
||||
];
|
||||
|
||||
// Tasks with columnMovedAt should come first, sorted by columnMovedAt descending (newest first)
|
||||
// So FN-002 (12:00) should be first, FN-001 (10:00) second
|
||||
// Legacy tasks (no columnMovedAt) come last, sorted by createdAt ascending
|
||||
expect(todoTasks[0].id).toBe("FN-002");
|
||||
expect(todoTasks[1].id).toBe("FN-001");
|
||||
expect(todoTasks[2].id).toBe("FN-003");
|
||||
renderBoard({ tasks });
|
||||
|
||||
const ipTasks = JSON.parse(screen.getByTestId("column-in-progress").getAttribute("data-tasks") || "[]") as Task[];
|
||||
expect(ipTasks.map((t: Task) => t.id)).toEqual(["FN-010", "FN-030", "FN-050"]);
|
||||
});
|
||||
|
||||
it("normalizes missing priority values to normal", () => {
|
||||
const noPriorityTask = createTask({ id: "FN-060", description: "No priority", column: "todo" });
|
||||
delete noPriorityTask.priority;
|
||||
|
||||
const tasks: Task[] = [
|
||||
noPriorityTask,
|
||||
createTask({ id: "FN-061", description: "Explicit normal", column: "todo", priority: "normal" }),
|
||||
createTask({ id: "FN-062", description: "Urgent", column: "todo", priority: "urgent" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks });
|
||||
|
||||
const todoTasks = JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]") as Task[];
|
||||
// FN-060 (missing → normal) and FN-061 (explicit normal) are same priority,
|
||||
// so they sort by numeric ID ascending
|
||||
expect(todoTasks.map((t: Task) => t.id)).toEqual(["FN-062", "FN-060", "FN-061"]);
|
||||
});
|
||||
|
||||
it("uses localeCompare fallback for non-numeric task IDs", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "TASK-002", description: "Task two", column: "todo", priority: "normal" }),
|
||||
createTask({ id: "TASK-001", description: "Task one", column: "todo", priority: "normal" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks });
|
||||
|
||||
const todoTasks = JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]") as Task[];
|
||||
// Both have same priority, numeric parse fails (NaN), localeCompare fallback
|
||||
expect(todoTasks.map((t: Task) => t.id)).toEqual(["TASK-001", "TASK-002"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortTasksForColumn merging pinning", () => {
|
||||
@@ -376,61 +397,71 @@ describe("Board", () => {
|
||||
expect(inReviewTasks.map((task) => task.id)).toEqual(["FN-020", "FN-021"]);
|
||||
});
|
||||
|
||||
it("sorts multiple merging tasks by columnMovedAt descending within the pinned group", () => {
|
||||
it("sorts multiple merging tasks by priority then task ID within the pinned group", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({
|
||||
id: "FN-030",
|
||||
column: "in-review",
|
||||
status: "merging",
|
||||
columnMovedAt: "2024-01-01T09:00:00.000Z",
|
||||
priority: "high",
|
||||
}),
|
||||
createTask({
|
||||
id: "FN-031",
|
||||
column: "in-review",
|
||||
status: "merging-pr",
|
||||
columnMovedAt: "2024-01-01T11:00:00.000Z",
|
||||
priority: "urgent",
|
||||
}),
|
||||
createTask({
|
||||
id: "FN-032",
|
||||
column: "in-review",
|
||||
status: "review-ready",
|
||||
columnMovedAt: "2024-01-01T12:00:00.000Z",
|
||||
priority: "urgent",
|
||||
}),
|
||||
];
|
||||
|
||||
renderBoard({ tasks });
|
||||
|
||||
const inReviewTasks = JSON.parse(screen.getByTestId("column-in-review").getAttribute("data-tasks") || "[]") as Task[];
|
||||
// Pinned group (merging): FN-031 urgent, FN-030 high — sorted by priority desc
|
||||
// Non-pinned group: FN-032 urgent
|
||||
expect(inReviewTasks.map((task) => task.id)).toEqual(["FN-031", "FN-030", "FN-032"]);
|
||||
});
|
||||
|
||||
it("does not change sort order for non-in-review columns", () => {
|
||||
it("sorts non-in-review columns by priority then task ID regardless of status", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({
|
||||
id: "FN-040",
|
||||
column: "todo",
|
||||
status: "merging",
|
||||
columnMovedAt: "2024-01-01T10:00:00.000Z",
|
||||
priority: "high",
|
||||
}),
|
||||
createTask({
|
||||
id: "FN-041",
|
||||
column: "todo",
|
||||
status: "ready",
|
||||
columnMovedAt: "2024-01-01T12:00:00.000Z",
|
||||
priority: "urgent",
|
||||
}),
|
||||
createTask({
|
||||
id: "FN-042",
|
||||
column: "todo",
|
||||
status: "ready",
|
||||
priority: "high",
|
||||
}),
|
||||
];
|
||||
|
||||
renderBoard({ tasks });
|
||||
|
||||
const todoTasks = JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]") as Task[];
|
||||
expect(todoTasks.map((task) => task.id)).toEqual(["FN-041", "FN-040"]);
|
||||
// No merge-pinning outside in-review, so pure priority-then-ID sort
|
||||
// FN-041 urgent, then FN-040 and FN-042 both high (sorted by ID asc)
|
||||
expect(todoTasks.map((task) => task.id)).toEqual(["FN-041", "FN-040", "FN-042"]);
|
||||
});
|
||||
|
||||
it("keeps tasks without status sorting normally in in-review", () => {
|
||||
it("sorts tasks without status by priority then task ID in in-review", () => {
|
||||
const statuslessTask = createTask({
|
||||
id: "FN-050",
|
||||
column: "in-review",
|
||||
columnMovedAt: "2024-01-01T11:00:00.000Z",
|
||||
priority: "normal",
|
||||
});
|
||||
delete statuslessTask.status;
|
||||
|
||||
@@ -440,13 +471,14 @@ describe("Board", () => {
|
||||
id: "FN-051",
|
||||
column: "in-review",
|
||||
status: "review-ready",
|
||||
columnMovedAt: "2024-01-01T12:00:00.000Z",
|
||||
priority: "urgent",
|
||||
}),
|
||||
];
|
||||
|
||||
renderBoard({ tasks });
|
||||
|
||||
const inReviewTasks = JSON.parse(screen.getByTestId("column-in-review").getAttribute("data-tasks") || "[]") as Task[];
|
||||
// Neither is merging, so sort by priority: FN-051 urgent > FN-050 normal
|
||||
expect(inReviewTasks.map((task) => task.id)).toEqual(["FN-051", "FN-050"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2589,6 +2589,7 @@ describe("ChatView mobile behavior", () => {
|
||||
|
||||
it("mobile mode: sets and clears keyboard overlap CSS vars on chat thread", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
_resetInitialViewportHeight();
|
||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
vvHeight: 844,
|
||||
@@ -2606,6 +2607,11 @@ describe("ChatView mobile behavior", () => {
|
||||
expect(thread).toBeInTheDocument();
|
||||
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
|
||||
// Focus the chat textarea so the hook treats the active element as a
|
||||
// keyboard-focusable target.
|
||||
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: 560,
|
||||
writable: true,
|
||||
@@ -2626,6 +2632,9 @@ describe("ChatView mobile behavior", () => {
|
||||
expect(thread.style.getPropertyValue("--vv-height")).toBe("560px");
|
||||
});
|
||||
|
||||
// Blur to signal keyboard dismissal
|
||||
textarea.blur();
|
||||
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 844,
|
||||
writable: true,
|
||||
@@ -2719,6 +2728,11 @@ describe("ChatView mobile behavior", () => {
|
||||
});
|
||||
messagesContainer.scrollTop = 0;
|
||||
|
||||
// Focus the chat textarea so the hook treats the active element as a
|
||||
// keyboard-focusable target.
|
||||
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: 560,
|
||||
writable: true,
|
||||
|
||||
Reference in New Issue
Block a user