[ERP-73] Paginer toutes les listes côté front + composable de liste paginée réutilisable (#30)
Auto Tag Develop / tag (push) Successful in 8s
Auto Tag Develop / tag (push) Successful in 8s
## Contexte Ticket Lesstime : #73 (id 492) — volet front de la pagination (groupe Transversal). Dépend du back ERP-72 (déjà mergé sur develop). Pas de spec docs/specs ; référence = description #73 + .claude/rules/frontend.md. ## Implémentation - Composable réutilisable `usePaginatedList` (`frontend/shared/composables/`) générique, branché directement sur `MalioDataTable` (props page/perPage/totalItems + events update:page/update:per-page). - Force `Accept: application/ld+json` (sans Accept, API Platform renvoie un tableau plat sans pagination). - Migration des pages admin existantes (M0 catégories, Sites, Utilisateurs, Rôles) vers le composable. - Refactor de `useCategoriesAdmin` : ne porte plus la liste paginée (déplacée vers `usePaginatedList<Category>` dans la page) et concentre son rôle sur le référentiel `CategoryType` (chargé en une fois via `?pagination=false`, échappatoire prévue par `pagination_client_enabled: true` côté back). - Cas limites couverts : liste vide (pas de contrôle pagination affiché), page hors borne après filtre (retombe sur la dernière page valide), items/page 10/25/50, reset filtres/tri, swallow erreur réseau. - Pattern « liste paginée » documenté dans `.claude/rules/frontend.md` (section dédiée + exemple). ## Décision URL Le ticket suggérait « idéalement page/tri/filtre dans l'URL » — arbitré explicitement par Tristan en faveur de la règle ABSOLUE n°6 du CLAUDE.md (state local uniquement, jamais persisté dans l'URL). Aucun reflet URL implémenté ; comportement homogène entre toutes les listes migrées. ## Tests - `make nuxt-test` : 101/101 OK (22 nouveaux tests sur `usePaginatedList`, 6 anciens tests `useCategoriesAdmin.fetchAll` retirés en cohérence avec la refacto). - Vérification manuelle dans le navigateur (`make dev-nuxt`) : Sites, Utilisateurs, Rôles, Catégories affichent le sélecteur `Lignes : 10` et les boutons Prev/Next ; audit-log (non migré, composable spécifique) intact avec ses 3 pages. - Aucun test E2E ajouté (règle d'or projet). - Pre-commit hook : ESLint + PHPUnit 322/322 OK. ## Hors périmètre - `audit-log.vue` non migré : composable `useAuditLog` spécifique (cache partagé page/timeline, filtres complexes, persistance URL préexistante). Refactor risqué et net-zéro pour ERP-73. - M1 répertoire clients : pas encore livré sur develop (seules les specs sont mergées via #23). Le futur écran consommera `usePaginatedList` dès sa création. Reviewed-on: #30 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #30.
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { usePaginatedList } from '../usePaginatedList'
|
||||
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
/**
|
||||
* Tests du composable `usePaginatedList`.
|
||||
*
|
||||
* Couvre les invariants critiques :
|
||||
* - parse Hydra (member / totalItems)
|
||||
* - navigation page (goToPage / next / prev / bornes)
|
||||
* - changement items/page → retour page 1
|
||||
* - mutation filtres / tri → retour page 1
|
||||
* - cas limite : page courante hors borne apres filtre → derniere page valide
|
||||
* - liste vide / page unique
|
||||
* - reset → defaults
|
||||
* - swallow d'erreur reseau (la promesse `fetch` ne reject jamais)
|
||||
* - header `Accept: application/ld+json` toujours envoye (besoin du
|
||||
* paginator Hydra cote API Platform 4).
|
||||
*/
|
||||
describe('usePaginatedList', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
})
|
||||
|
||||
function mockResponse(member: unknown[], totalItems: number): void {
|
||||
mockApiGet.mockResolvedValueOnce({ member, totalItems })
|
||||
}
|
||||
|
||||
it('fetch initial : page=1, itemsPerPage par defaut, parse Hydra', async () => {
|
||||
mockResponse([{ id: 1 }, { id: 2 }], 42)
|
||||
const list = usePaginatedList<{ id: number }>({ url: '/sites' })
|
||||
|
||||
await list.fetch()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||
expect(url).toBe('/sites')
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||
expect(opts).toMatchObject({
|
||||
toast: false,
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
})
|
||||
expect(list.items.value).toEqual([{ id: 1 }, { id: 2 }])
|
||||
expect(list.totalItems.value).toBe(42)
|
||||
expect(list.totalPages.value).toBe(5)
|
||||
expect(list.isEmpty.value).toBe(false)
|
||||
expect(list.isSinglePage.value).toBe(false)
|
||||
})
|
||||
|
||||
it('itemsPerPage personnalise est respecte au premier fetch', async () => {
|
||||
mockResponse([], 0)
|
||||
const list = usePaginatedList({ url: '/users', defaultItemsPerPage: 25 })
|
||||
await list.fetch()
|
||||
expect(mockApiGet.mock.calls[0][1]).toMatchObject({ itemsPerPage: 25 })
|
||||
expect(list.itemsPerPage.value).toBe(25)
|
||||
})
|
||||
|
||||
it('goToPage(N) declenche un nouvel appel avec page=N', async () => {
|
||||
mockResponse([{ id: 1 }], 30) // page 1
|
||||
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([{ id: 2 }], 30) // page 2
|
||||
await list.goToPage(2)
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(2)
|
||||
expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2, itemsPerPage: 10 })
|
||||
expect(list.currentPage.value).toBe(2)
|
||||
})
|
||||
|
||||
it('goToPage hors borne sup. est clampe a totalPages', async () => {
|
||||
mockResponse([], 30) // totalPages = 3
|
||||
const list = usePaginatedList({ url: '/roles' })
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 30)
|
||||
await list.goToPage(999)
|
||||
|
||||
expect(list.currentPage.value).toBe(3)
|
||||
})
|
||||
|
||||
it('goToPage hors borne inf. est clampe a 1 (no-op si deja en 1)', async () => {
|
||||
mockResponse([], 30)
|
||||
const list = usePaginatedList({ url: '/roles' })
|
||||
await list.fetch()
|
||||
|
||||
mockApiGet.mockClear()
|
||||
await list.goToPage(-5)
|
||||
|
||||
// Deja en page 1 -> aucun nouvel appel.
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
})
|
||||
|
||||
it('nextPage / prevPage avancent et reculent dans les bornes', async () => {
|
||||
mockResponse([], 30) // page 1, totalPages 3
|
||||
const list = usePaginatedList({ url: '/roles' })
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 30)
|
||||
await list.nextPage()
|
||||
expect(list.currentPage.value).toBe(2)
|
||||
|
||||
mockResponse([], 30)
|
||||
await list.nextPage()
|
||||
expect(list.currentPage.value).toBe(3)
|
||||
|
||||
// En derniere page -> no-op
|
||||
mockApiGet.mockClear()
|
||||
await list.nextPage()
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
||||
expect(list.currentPage.value).toBe(3)
|
||||
|
||||
mockResponse([], 30)
|
||||
await list.prevPage()
|
||||
expect(list.currentPage.value).toBe(2)
|
||||
})
|
||||
|
||||
it('setItemsPerPage revient en page 1 et refetch', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
// place-toi page 3
|
||||
mockResponse([], 100)
|
||||
await list.goToPage(3)
|
||||
expect(list.currentPage.value).toBe(3)
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setItemsPerPage(25)
|
||||
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
expect(list.itemsPerPage.value).toBe(25)
|
||||
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1, itemsPerPage: 25 })
|
||||
})
|
||||
|
||||
it('setItemsPerPage no-op si meme valeur', async () => {
|
||||
mockResponse([], 10)
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
|
||||
mockApiGet.mockClear()
|
||||
await list.setItemsPerPage(10)
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it('setFilters fusionne et retombe en page 1', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList<unknown, { name?: string; active?: boolean }>({
|
||||
url: '/users',
|
||||
defaultFilters: { active: true },
|
||||
})
|
||||
await list.fetch()
|
||||
mockResponse([], 100)
|
||||
await list.goToPage(2)
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setFilters({ name: 'alice' })
|
||||
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
expect(list.filters.value).toEqual({ active: true, name: 'alice' })
|
||||
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({
|
||||
page: 1,
|
||||
active: true,
|
||||
name: 'alice',
|
||||
})
|
||||
})
|
||||
|
||||
it('setFilters({ key: undefined }) supprime la cle', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList<unknown, { name?: string }>({
|
||||
url: '/users',
|
||||
defaultFilters: { name: 'alice' },
|
||||
})
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setFilters({ name: undefined })
|
||||
|
||||
expect(list.filters.value).toEqual({})
|
||||
// Le query envoye ne contient plus `name` (compactQuery elimine
|
||||
// aussi les valeurs vides).
|
||||
const q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(q.name).toBeUndefined()
|
||||
})
|
||||
|
||||
it('setFilters({ replace: true }) remplace integralement', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList<unknown, { a?: string; b?: string }>({
|
||||
url: '/users',
|
||||
defaultFilters: { a: 'x' },
|
||||
})
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setFilters({ b: 'y' }, { replace: true })
|
||||
|
||||
expect(list.filters.value).toEqual({ b: 'y' })
|
||||
})
|
||||
|
||||
it('setSort envoie order[field]=direction et reset page', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
mockResponse([], 100)
|
||||
await list.goToPage(2)
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setSort({ field: 'username', direction: 'desc' })
|
||||
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
const q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(q['order[username]']).toBe('desc')
|
||||
})
|
||||
|
||||
it('setSort(null) retire le tri', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList({
|
||||
url: '/users',
|
||||
defaultSort: { field: 'name', direction: 'asc' },
|
||||
})
|
||||
await list.fetch()
|
||||
// Le tri initial est applique
|
||||
let q = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||
expect(q['order[name]']).toBe('asc')
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setSort(null)
|
||||
q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(q['order[name]']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('setFilters retombe en page 1 (cas standard, pas le hors-borne)', async () => {
|
||||
// setFilters remet toujours page=1 avant de refetcher : ce n'est
|
||||
// donc PAS le chemin de retry hors-borne (couvert par le test
|
||||
// suivant via un refetch a page constante). On verifie juste le
|
||||
// reset de page ici.
|
||||
mockResponse([], 50) // 5 pages
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
mockResponse([], 50)
|
||||
await list.goToPage(5)
|
||||
expect(list.currentPage.value).toBe(5)
|
||||
|
||||
mockResponse([{ id: 1 }, { id: 2 }], 12)
|
||||
await list.setFilters({ active: true } as never)
|
||||
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1 })
|
||||
})
|
||||
|
||||
it('declenche le retry sur derniere page si currentPage > totalPages apres fetch', async () => {
|
||||
// Scenario : on a fait un fetch (5 pages, page=1). Sans toucher aux
|
||||
// filtres mais entre deux fetchs la donnee a change cote serveur,
|
||||
// la page courante peut devenir hors borne. On force le scenario
|
||||
// en montant manuellement currentPage via goToPage borne, puis en
|
||||
// simulant une reponse plus petite.
|
||||
mockResponse([], 50) // 5 pages
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 50)
|
||||
await list.goToPage(5)
|
||||
expect(list.currentPage.value).toBe(5)
|
||||
|
||||
// Maintenant simule : refetch -> totalItems chute a 12 (2 pages),
|
||||
// le composable doit refetcher sur page=2.
|
||||
mockApiGet.mockReset()
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce({ member: [], totalItems: 12 }) // page=5 vide
|
||||
.mockResolvedValueOnce({ member: [{ id: 11 }, { id: 12 }], totalItems: 12 }) // page=2
|
||||
|
||||
await list.fetch()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(2)
|
||||
expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2 })
|
||||
expect(list.currentPage.value).toBe(2)
|
||||
expect(list.items.value).toEqual([{ id: 11 }, { id: 12 }])
|
||||
})
|
||||
|
||||
it('liste vide : isEmpty true, isSinglePage true', async () => {
|
||||
mockResponse([], 0)
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
|
||||
expect(list.totalItems.value).toBe(0)
|
||||
expect(list.isEmpty.value).toBe(true)
|
||||
expect(list.isSinglePage.value).toBe(true)
|
||||
expect(list.totalPages.value).toBe(1)
|
||||
})
|
||||
|
||||
it('isEmpty est faux avant le premier fetch (etat indetermine)', () => {
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
expect(list.isEmpty.value).toBe(false)
|
||||
})
|
||||
|
||||
it('reset revient aux defaults', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList<unknown, { a?: string }>({
|
||||
url: '/users',
|
||||
defaultItemsPerPage: 10,
|
||||
defaultFilters: { a: 'x' },
|
||||
defaultSort: { field: 'name', direction: 'asc' },
|
||||
})
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setItemsPerPage(50)
|
||||
mockResponse([], 100)
|
||||
await list.setFilters({ a: 'y' })
|
||||
mockResponse([], 100)
|
||||
await list.setSort({ field: 'id', direction: 'desc' })
|
||||
mockResponse([], 100)
|
||||
await list.goToPage(2)
|
||||
expect(list.currentPage.value).toBe(2)
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.reset()
|
||||
|
||||
expect(list.itemsPerPage.value).toBe(10)
|
||||
expect(list.filters.value).toEqual({ a: 'x' })
|
||||
expect(list.sort.value).toEqual({ field: 'name', direction: 'asc' })
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
})
|
||||
|
||||
it('swallow l\'erreur reseau : items vides, loading false, fetch ne reject pas', async () => {
|
||||
const boom = new Error('boom')
|
||||
mockApiGet.mockRejectedValueOnce(boom)
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
|
||||
await expect(list.fetch()).resolves.toBeUndefined()
|
||||
expect(list.items.value).toEqual([])
|
||||
expect(list.totalItems.value).toBe(0)
|
||||
expect(list.loading.value).toBe(false)
|
||||
// L'erreur est consideree comme un fetch consume -> isEmpty=true.
|
||||
expect(list.isEmpty.value).toBe(true)
|
||||
// ... mais `error` est expose pour distinguer « vide » d'« echec ».
|
||||
expect(list.error.value).toBe(boom)
|
||||
})
|
||||
|
||||
it('error est remis a null des qu\'un fetch ulterieur reussit', async () => {
|
||||
mockApiGet.mockRejectedValueOnce(new Error('boom'))
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
expect(list.error.value).toBeInstanceOf(Error)
|
||||
|
||||
mockResponse([{ id: 1 }], 1)
|
||||
await list.fetch()
|
||||
expect(list.error.value).toBeNull()
|
||||
expect(list.items.value).toEqual([{ id: 1 }])
|
||||
})
|
||||
|
||||
it('ignore une reponse periemee : la derniere requete *demandee* gagne', async () => {
|
||||
// Deux fetch concurrents : le 1er resout APRES le 2eme. Sans garde
|
||||
// de sequence, la reponse arrivee en dernier (token 1) ecraserait
|
||||
// les donnees plus fraiches du token 2. Avec la garde, token 2 fait
|
||||
// foi quel que soit l'ordre d'arrivee reseau.
|
||||
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
||||
|
||||
let resolveSlow!: (v: unknown) => void
|
||||
const slow = new Promise((r) => { resolveSlow = r })
|
||||
// 1er appel : reponse lente (en vol).
|
||||
mockApiGet.mockReturnValueOnce(slow)
|
||||
// 2eme appel : reponse immediate avec des donnees plus fraiches.
|
||||
mockApiGet.mockResolvedValueOnce({ member: [{ id: 2 }], totalItems: 30 })
|
||||
|
||||
const p1 = list.fetch() // token 1, en vol
|
||||
const p2 = list.fetch() // token 2, resout tout de suite
|
||||
await p2
|
||||
expect(list.items.value).toEqual([{ id: 2 }])
|
||||
|
||||
// La reponse lente du token 1 arrive enfin : elle doit etre ignoree.
|
||||
resolveSlow({ member: [{ id: 1 }], totalItems: 30 })
|
||||
await p1
|
||||
expect(list.items.value).toEqual([{ id: 2 }])
|
||||
// Le spinner reste eteint (la requete recente l'avait deja coupe).
|
||||
expect(list.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('extraQuery est injecte a chaque fetch (ex : includeDeleted)', async () => {
|
||||
mockResponse([], 0)
|
||||
const list = usePaginatedList({
|
||||
url: '/categories',
|
||||
extraQuery: { includeDeleted: 'true' },
|
||||
})
|
||||
await list.fetch()
|
||||
|
||||
expect(mockApiGet.mock.calls[0][1]).toMatchObject({ includeDeleted: 'true' })
|
||||
})
|
||||
|
||||
it('valeurs nulles/vides des filtres ne sont pas envoyees', async () => {
|
||||
mockResponse([], 0)
|
||||
const list = usePaginatedList<unknown, { name?: string; q?: string }>({
|
||||
url: '/users',
|
||||
defaultFilters: { name: '', q: undefined } as never,
|
||||
})
|
||||
await list.fetch()
|
||||
|
||||
const q = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||
expect(q.name).toBeUndefined()
|
||||
expect(q.q).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refresh() est un alias de fetch()', async () => {
|
||||
mockResponse([{ id: 1 }], 1)
|
||||
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
||||
|
||||
await list.refresh()
|
||||
expect(list.items.value).toEqual([{ id: 1 }])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user