feat: refonte UX du dashboard (coquille, tri/filtre/pagination, ⌘K, toasts)
Refonte front (packages/web) : coquille applicative partagée (sidebar desktop + barre d'onglets mobile + PageHeader, langue/push/logout centralisés) pilotée par route.meta.layout ; design system (BaseButton/BaseBadge/SegmentedControl/EmptyState/ SkeletonRow, anneau de focus, icônes @lucide/vue) ; tri/filtre/recherche/pagination côté client via useListControls (+ synchro URL) sur sessions/worktrees/groupes ; section « À traiter », palette de commandes ⌘K, toasts. Aucune modif serveur/protocole/DB.
This commit is contained in:
87
packages/web/test/use-list-controls.test.ts
Normal file
87
packages/web/test/use-list-controls.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useListControls, type FilterDef, type SortDef } from '../src/composables/useListControls';
|
||||
|
||||
interface Item {
|
||||
n: number;
|
||||
}
|
||||
|
||||
const sorts: SortDef<Item>[] = [
|
||||
{ key: 'n', labelKey: 'n', compare: (a, b) => a.n - b.n, defaultDir: 'asc' },
|
||||
];
|
||||
const filters: FilterDef<Item>[] = [
|
||||
{
|
||||
key: 'parity',
|
||||
labelKey: 'parity',
|
||||
options: [
|
||||
{ value: 'even', label: 'even' },
|
||||
{ value: 'odd', label: 'odd' },
|
||||
],
|
||||
predicate: (it, active) =>
|
||||
(active.has('even') && it.n % 2 === 0) || (active.has('odd') && it.n % 2 === 1),
|
||||
},
|
||||
];
|
||||
|
||||
function make() {
|
||||
const source = ref<Item[]>([{ n: 5 }, { n: 2 }, { n: 4 }, { n: 1 }, { n: 3 }]);
|
||||
const c = useListControls<Item>({ source: () => source.value, sorts, filters, defaultPageSize: 2 });
|
||||
return { source, c };
|
||||
}
|
||||
|
||||
describe('useListControls', () => {
|
||||
it('trie en ascendant par défaut', () => {
|
||||
const { c } = make();
|
||||
expect(c.processed.value.map((i) => i.n)).toEqual([1, 2]); // page 1, taille 2
|
||||
expect(c.total.value).toBe(5);
|
||||
expect(c.pageCount.value).toBe(3);
|
||||
});
|
||||
|
||||
it('inverse la direction au re-clic de la même clé', () => {
|
||||
const { c } = make();
|
||||
c.setSort('n'); // même clé → desc
|
||||
expect(c.sortDir.value).toBe('desc');
|
||||
expect(c.processed.value.map((i) => i.n)).toEqual([5, 4]);
|
||||
});
|
||||
|
||||
it('pagine et borne la page', () => {
|
||||
const { c } = make();
|
||||
c.setPage(3);
|
||||
expect(c.processed.value.map((i) => i.n)).toEqual([5]);
|
||||
c.setPage(99);
|
||||
expect(c.page.value).toBe(3); // borné à pageCount
|
||||
});
|
||||
|
||||
it('filtre (OR intra-filtre) et revient page 1', () => {
|
||||
const { c } = make();
|
||||
c.setPage(2);
|
||||
c.toggleFilter('parity', 'even');
|
||||
expect(c.page.value).toBe(1);
|
||||
expect(c.total.value).toBe(2); // 2 et 4
|
||||
expect(c.processed.value.map((i) => i.n)).toEqual([2, 4]);
|
||||
});
|
||||
|
||||
it('setSearch revient page 1, mais écrire search.value directement (hydratation) ne touche pas la page', () => {
|
||||
const { c } = make();
|
||||
c.setPage(3);
|
||||
// hydratation URL : on écrit le ref directement → la page fournie par l'URL est préservée
|
||||
c.search.value = '';
|
||||
expect(c.page.value).toBe(3);
|
||||
// saisie utilisateur via setSearch → retour page 1
|
||||
c.setSearch('x');
|
||||
expect(c.page.value).toBe(1);
|
||||
});
|
||||
|
||||
it('pageSize 0 affiche tout', () => {
|
||||
const { c } = make();
|
||||
c.setPageSize(0);
|
||||
expect(c.pageCount.value).toBe(1);
|
||||
expect(c.processed.value.map((i) => i.n)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it('reste réactif aux mutations de la source (upsert temps réel)', () => {
|
||||
const { source, c } = make();
|
||||
source.value = [...source.value, { n: 0 }];
|
||||
expect(c.total.value).toBe(6);
|
||||
expect(c.processed.value.map((i) => i.n)).toEqual([0, 1]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user