forked from boos/BoosAPI
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:
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user