feat: add sorting and pinning functionality for accounts and sites

- Introduced `isPinned` and `sortOrder` fields in the `accounts` and `sites` tables to allow users to pin and sort their accounts and sites.
- Updated API routes to handle pinning and sorting operations for accounts and sites.
- Enhanced the frontend to support sorting by balance and custom order, as well as toggling the pin status of accounts and sites.
- Added tests for sorting and reordering logic to ensure correct functionality.
- Updated documentation to reflect changes in token management and new features.
This commit is contained in:
cita
2026-02-28 23:45:30 +08:00
parent 08eaf11ea2
commit 44af9a91cf
17 changed files with 647 additions and 67 deletions
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './listSorting.js';
type Item = {
id: number;
isPinned?: boolean | null;
sortOrder?: number | null;
balance?: number | null;
};
function ids(items: Item[]): number[] {
return items.map((item) => item.id);
}
describe('sortItemsForDisplay', () => {
const base: Item[] = [
{ id: 1, isPinned: false, sortOrder: 2, balance: 5 },
{ id: 2, isPinned: true, sortOrder: 1, balance: 1 },
{ id: 3, isPinned: false, sortOrder: 0, balance: 20 },
{ id: 4, isPinned: true, sortOrder: 0, balance: 10 },
];
it('keeps pinned items first in custom mode', () => {
const mode: SortMode = 'custom';
const sorted = sortItemsForDisplay(base, mode, (item) => item.balance || 0);
expect(ids(sorted)).toEqual([4, 2, 3, 1]);
});
it('sorts by balance desc while keeping pinned items first', () => {
const sorted = sortItemsForDisplay(base, 'balance-desc', (item) => item.balance || 0);
expect(ids(sorted)).toEqual([4, 2, 3, 1]);
});
it('sorts by balance asc while keeping pinned items first', () => {
const sorted = sortItemsForDisplay(base, 'balance-asc', (item) => item.balance || 0);
expect(ids(sorted)).toEqual([2, 4, 1, 3]);
});
});
describe('buildCustomReorderUpdates', () => {
const list: Item[] = [
{ id: 10, isPinned: true, sortOrder: 0 },
{ id: 11, isPinned: true, sortOrder: 1 },
{ id: 20, isPinned: false, sortOrder: 0 },
{ id: 21, isPinned: false, sortOrder: 1 },
];
it('reorders only inside the same pinned group', () => {
const updates = buildCustomReorderUpdates(list, 20, 'up');
// First unpinned item cannot move above pinned group.
expect(updates).toEqual([]);
});
it('returns normalized sortOrder updates after moving down', () => {
const updates = buildCustomReorderUpdates(list, 20, 'down');
expect(updates).toEqual([
{ id: 21, sortOrder: 0 },
{ id: 20, sortOrder: 1 },
]);
});
});
+76
View File
@@ -0,0 +1,76 @@
export type SortMode = 'custom' | 'balance-desc' | 'balance-asc';
type SortableBase = {
id: number;
isPinned?: boolean | null;
sortOrder?: number | null;
};
export function sortItemsForDisplay<T extends SortableBase>(
items: T[],
mode: SortMode,
getBalance: (item: T) => number,
): T[] {
const list = [...items];
const customComparator = (a: T, b: T) => {
const aPinned = a.isPinned ? 1 : 0;
const bPinned = b.isPinned ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
const aOrder = Number.isFinite(a.sortOrder as number) ? Number(a.sortOrder) : Number.MAX_SAFE_INTEGER;
const bOrder = Number.isFinite(b.sortOrder as number) ? Number(b.sortOrder) : Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) return aOrder - bOrder;
return a.id - b.id;
};
if (mode === 'custom') {
return list.sort(customComparator);
}
return list.sort((a, b) => {
const aPinned = a.isPinned ? 1 : 0;
const bPinned = b.isPinned ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
const aBalance = Number.isFinite(getBalance(a)) ? getBalance(a) : 0;
const bBalance = Number.isFinite(getBalance(b)) ? getBalance(b) : 0;
if (aBalance !== bBalance) {
return mode === 'balance-desc' ? bBalance - aBalance : aBalance - bBalance;
}
return customComparator(a, b);
});
}
export function buildCustomReorderUpdates<T extends SortableBase>(
items: T[],
targetId: number,
direction: 'up' | 'down',
): Array<{ id: number; sortOrder: number }> {
const sorted = sortItemsForDisplay(items, 'custom', () => 0);
const target = sorted.find((item) => item.id === targetId);
if (!target) return [];
const targetPinned = !!target.isPinned;
const group = sorted.filter((item) => !!item.isPinned === targetPinned);
const index = group.findIndex((item) => item.id === targetId);
if (index < 0) return [];
const swapIndex = direction === 'up' ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= group.length) return [];
const next = [...group];
const temp = next[index];
next[index] = next[swapIndex];
next[swapIndex] = temp;
const updates: Array<{ id: number; sortOrder: number }> = [];
next.forEach((item, idx) => {
const prev = Number.isFinite(item.sortOrder as number) ? Number(item.sortOrder) : Number.MAX_SAFE_INTEGER;
if (prev !== idx) {
updates.push({ id: item.id, sortOrder: idx });
}
});
return updates;
}