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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan LEROY
2026-06-18 11:47:36 +02:00
parent 8a8018a6fc
commit b0e8a93caa
41 changed files with 2416 additions and 448 deletions

View 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]);
});
});