From 4ff1657f3bc7b272e707adaa8435cb5800e44f14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Fri, 6 Nov 2026 14:50:53 +0300 Subject: [PATCH] Keep pinned boards above sorted lists --- .../components/tasks/TaskBoardNavigator.tsx | 120 ++++------------- frontend/src/lib/task-board-order.test.js | 65 +++++++++ frontend/src/lib/task-board-order.ts | 124 ++++++++++++++++++ 3 files changed, 214 insertions(+), 95 deletions(-) create mode 100644 frontend/src/lib/task-board-order.test.js create mode 100644 frontend/src/lib/task-board-order.ts diff --git a/frontend/src/components/tasks/TaskBoardNavigator.tsx b/frontend/src/components/tasks/TaskBoardNavigator.tsx index 1609e092..21f32adc 100644 --- a/frontend/src/components/tasks/TaskBoardNavigator.tsx +++ b/frontend/src/components/tasks/TaskBoardNavigator.tsx @@ -59,20 +59,17 @@ import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller"; import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction"; import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu"; import { displayUserName } from "@/lib/shared"; +import { + boardGroupPosition, + sortTaskBoards, + taskBoardReorderNeighbours, + type BoardSortValue, +} from "@/lib/task-board-order"; import type { TaskBoardGroup, TaskBoardSummary } from "@/types/api"; type BoardUnreadFilter = "all" | "unread" | "read"; type BoardAccessFilter = "all" | "private" | "shared"; type BoardGroupFilter = "all" | "ungrouped" | number; -type BoardSortValue = - | "priority" - | "priority-desc" - | "name" - | "name-desc" - | "newest" - | "oldest" - | "updated"; - const BOARD_PRIORITY_SORT_OPTIONS = [ { value: "priority", label: "По приоритету ↑", icon: "priority-asc" }, { value: "priority-desc", label: "По приоритету ↓", icon: "priority-desc" }, @@ -97,25 +94,6 @@ function boardGroupIds(board: TaskBoardSummary): number[] { return board.group_ids || (board.group_id ? [board.group_id] : []); } -function boardGroupPosition( - board: TaskBoardSummary, - groupId: number, -): number | undefined { - return board.group_positions?.[String(groupId)]; -} - -function compareBoardNames( - first: TaskBoardSummary, - second: TaskBoardSummary, -): number { - return first.name.localeCompare(second.name, "ru") || first.id - second.id; -} - -function boardTimestamp(value: string): number { - const timestamp = Date.parse(value); - return Number.isFinite(timestamp) ? timestamp : 0; -} - function sameGroupIds(left: number[], right: number[]): boolean { const normalizedLeft = [...left].sort((first, second) => first - second); const normalizedRight = [...right].sort((first, second) => first - second); @@ -562,69 +540,18 @@ export default function TaskBoardNavigator({ return false; return !normalizedQuery || matchesBoard(board, normalizedQuery); }); - const sortedBoards = matchingBoards.sort((first, second) => { - if ( - (effectiveBoardSort === "priority" || - effectiveBoardSort === "priority-desc") && - typeof effectiveGroupFilter === "number" - ) { - const firstPosition = boardGroupPosition(first, effectiveGroupFilter); - const secondPosition = boardGroupPosition(second, effectiveGroupFilter); - if ( - firstPosition !== undefined && - secondPosition !== undefined && - firstPosition !== secondPosition - ) { - const direction = effectiveBoardSort === "priority-desc" ? -1 : 1; - return (firstPosition - secondPosition) * direction; - } - if (firstPosition !== undefined && secondPosition === undefined) - return -1; - if (firstPosition === undefined && secondPosition !== undefined) - return 1; - } - - if (effectiveBoardSort === "name-desc") { - return ( - second.name.localeCompare(first.name, "ru") || second.id - first.id - ); - } - if (effectiveBoardSort === "newest") { - return ( - boardTimestamp(second.created_at) - - boardTimestamp(first.created_at) || compareBoardNames(first, second) - ); - } - if (effectiveBoardSort === "oldest") { - return ( - boardTimestamp(first.created_at) - - boardTimestamp(second.created_at) || - compareBoardNames(first, second) - ); - } - if (effectiveBoardSort === "updated") { - return ( - boardTimestamp(second.updated_at) - - boardTimestamp(first.updated_at) || compareBoardNames(first, second) - ); - } - return compareBoardNames(first, second); - }); - if ( + const optimisticIds = optimisticBoardOrder && typeof effectiveGroupFilter === "number" && optimisticBoardOrder.groupId === effectiveGroupFilter - ) { - const optimisticIndexes = new Map( - optimisticBoardOrder.boardIds.map((boardId, index) => [boardId, index]), - ); - return sortedBoards.sort( - (first, second) => - (optimisticIndexes.get(first.id) ?? Number.MAX_SAFE_INTEGER) - - (optimisticIndexes.get(second.id) ?? Number.MAX_SAFE_INTEGER), - ); - } - return sortedBoards; + ? optimisticBoardOrder.boardIds + : undefined; + return sortTaskBoards( + matchingBoards, + effectiveBoardSort, + typeof effectiveGroupFilter === "number" ? effectiveGroupFilter : null, + optimisticIds, + ); }, [ effectiveBoardSort, effectiveGroupFilter, @@ -712,12 +639,15 @@ export default function TaskBoardNavigator({ const newIndex = visibleBoards.findIndex((item) => item.id === overBoardId); if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return; - const reorderedBoards = arrayMove(visibleBoards, oldIndex, newIndex); - const movedIndex = reorderedBoards.findIndex( - (item) => item.id === activeBoardId, + const neighbours = taskBoardReorderNeighbours( + visibleBoards, + effectiveGroupFilter, + activeBoardId, + overBoardId, ); - const previousBoardId = reorderedBoards[movedIndex - 1]?.id ?? null; - const nextBoardId = reorderedBoards[movedIndex + 1]?.id ?? null; + if (!neighbours) return; + + const reorderedBoards = arrayMove(visibleBoards, oldIndex, newIndex); setOptimisticBoardOrder({ groupId: effectiveGroupFilter, boardIds: reorderedBoards.map((item) => item.id), @@ -727,8 +657,8 @@ export default function TaskBoardNavigator({ await onReorderBoard( effectiveGroupFilter, activeBoardId, - previousBoardId, - nextBoardId, + neighbours.previousBoardId, + neighbours.nextBoardId, ); } finally { setOptimisticBoardOrder(null); diff --git a/frontend/src/lib/task-board-order.test.js b/frontend/src/lib/task-board-order.test.js new file mode 100644 index 00000000..69637d95 --- /dev/null +++ b/frontend/src/lib/task-board-order.test.js @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + sortTaskBoards, + taskBoardReorderNeighbours, +} from "./task-board-order.ts"; + +const board = (id, name, position, pinned, created, updated = created) => ({ + id, + name, + is_pinned: pinned, + group_positions: { 7: position }, + created_at: created, + updated_at: updated, +}); +const boards = [ + board(1, "Альфа", 1, false, "2026-01-01", "2026-01-04"), + board(2, "Бета", 2, true, "2026-01-04", "2026-01-02"), + board(3, "Вега", 3, false, "2026-01-03", "2026-01-01"), + board(4, "Якорь", 4, true, "2026-01-02", "2026-01-03"), +]; +const ids = (items) => items.map((item) => item.id); + +test("pinned boards precede other boards in every sort order", () => { + const expected = { + priority: [2, 4, 1, 3], + "priority-desc": [4, 2, 3, 1], + name: [2, 4, 1, 3], + "name-desc": [4, 2, 3, 1], + newest: [2, 4, 3, 1], + oldest: [4, 2, 1, 3], + updated: [4, 2, 1, 3], + }; + for (const [sort, order] of Object.entries(expected)) { + assert.deepEqual(ids(sortTaskBoards(boards, sort, 7)), order, sort); + } + assert.deepEqual(ids(boards), [1, 2, 3, 4]); +}); + +test("pinning and unpinning immediately changes only the current ordering", () => { + const initially = sortTaskBoards(boards, "name", 7); + const changed = boards.map((item) => + item.id === 4 ? { ...item, is_pinned: false } : item, + ); + assert.deepEqual(ids(initially), [2, 4, 1, 3]); + assert.deepEqual(ids(sortTaskBoards(changed, "name", 7)), [2, 1, 3, 4]); + assert.deepEqual( + ids(sortTaskBoards(boards, "name", 7, [1, 3, 4, 2])), + [4, 2, 1, 3], + ); +}); + +test("drag neighbours use underlying group priority, including interleaved pins", () => { + const visible = sortTaskBoards(boards, "priority", 7); + assert.deepEqual(taskBoardReorderNeighbours(visible, 7, 4, 2), { + previousBoardId: 1, + nextBoardId: 2, + }); + assert.deepEqual(taskBoardReorderNeighbours(visible, 7, 1, 3), { + previousBoardId: 3, + nextBoardId: 4, + }); + assert.equal(taskBoardReorderNeighbours(visible, 7, 4, 1), null); + assert.equal(taskBoardReorderNeighbours(visible, 7, 1, 1), null); +}); diff --git a/frontend/src/lib/task-board-order.ts b/frontend/src/lib/task-board-order.ts new file mode 100644 index 00000000..c1b109d3 --- /dev/null +++ b/frontend/src/lib/task-board-order.ts @@ -0,0 +1,124 @@ +import type { TaskBoardSummary } from "@/types/api"; + +export type BoardSortValue = + | "priority" + | "priority-desc" + | "name" + | "name-desc" + | "newest" + | "oldest" + | "updated"; + +export function boardGroupPosition(board: TaskBoardSummary, groupId: number) { + return board.group_positions?.[String(groupId)]; +} + +function compareNames(first: TaskBoardSummary, second: TaskBoardSummary) { + return first.name.localeCompare(second.name, "ru") || first.id - second.id; +} + +function timestamp(value: string) { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function compareWithoutPin( + first: TaskBoardSummary, + second: TaskBoardSummary, + sort: BoardSortValue, + groupId: number | null, +) { + if ((sort === "priority" || sort === "priority-desc") && groupId !== null) { + const firstPosition = boardGroupPosition(first, groupId); + const secondPosition = boardGroupPosition(second, groupId); + if ( + firstPosition !== undefined && + secondPosition !== undefined && + firstPosition !== secondPosition + ) { + return ( + (firstPosition - secondPosition) * (sort === "priority-desc" ? -1 : 1) + ); + } + if (firstPosition !== undefined && secondPosition === undefined) return -1; + if (firstPosition === undefined && secondPosition !== undefined) return 1; + } + if (sort === "name-desc") + return second.name.localeCompare(first.name, "ru") || second.id - first.id; + if (sort === "newest") + return ( + timestamp(second.created_at) - timestamp(first.created_at) || + compareNames(first, second) + ); + if (sort === "oldest") + return ( + timestamp(first.created_at) - timestamp(second.created_at) || + compareNames(first, second) + ); + if (sort === "updated") + return ( + timestamp(second.updated_at) - timestamp(first.updated_at) || + compareNames(first, second) + ); + return compareNames(first, second); +} + +export function sortTaskBoards( + boards: TaskBoardSummary[], + sort: BoardSortValue, + groupId: number | null, + optimisticIds?: number[], +) { + const optimisticIndexes = optimisticIds + ? new Map(optimisticIds.map((id, index) => [id, index])) + : null; + return [...boards].sort((first, second) => { + const pinnedDifference = + Number(Boolean(second.is_pinned)) - Number(Boolean(first.is_pinned)); + if (pinnedDifference) return pinnedDifference; + if (optimisticIndexes) { + const firstIndex = + optimisticIndexes.get(first.id) ?? Number.MAX_SAFE_INTEGER; + const secondIndex = + optimisticIndexes.get(second.id) ?? Number.MAX_SAFE_INTEGER; + if (firstIndex !== secondIndex) return firstIndex - secondIndex; + } + return compareWithoutPin(first, second, sort, groupId); + }); +} + +export function taskBoardReorderNeighbours( + visibleBoards: TaskBoardSummary[], + groupId: number, + activeBoardId: number, + overBoardId: number, +) { + const activeIndex = visibleBoards.findIndex( + (board) => board.id === activeBoardId, + ); + const overIndex = visibleBoards.findIndex( + (board) => board.id === overBoardId, + ); + if (activeIndex < 0 || overIndex < 0 || activeIndex === overIndex) + return null; + if ( + Boolean(visibleBoards[activeIndex].is_pinned) !== + Boolean(visibleBoards[overIndex].is_pinned) + ) + return null; + + const priorityOrder = visibleBoards + .filter((board) => board.id !== activeBoardId) + .sort((first, second) => + compareWithoutPin(first, second, "priority", groupId), + ); + const targetIndex = priorityOrder.findIndex( + (board) => board.id === overBoardId, + ); + if (targetIndex < 0) return null; + const insertIndex = targetIndex + (activeIndex < overIndex ? 1 : 0); + return { + previousBoardId: priorityOrder[insertIndex - 1]?.id ?? null, + nextBoardId: priorityOrder[insertIndex]?.id ?? null, + }; +}