Keep pinned boards above sorted lists
All checks were successful
Deploy production / deploy (push) Successful in 11m40s
All checks were successful
Deploy production / deploy (push) Successful in 11m40s
This commit is contained in:
parent
2d03dfa7cb
commit
4ff1657f3b
@ -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);
|
||||
|
||||
65
frontend/src/lib/task-board-order.test.js
Normal file
65
frontend/src/lib/task-board-order.test.js
Normal file
@ -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);
|
||||
});
|
||||
124
frontend/src/lib/task-board-order.ts
Normal file
124
frontend/src/lib/task-board-order.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user