43c3220873
- frontend/shared/composables/usePaginatedList.ts : composable generique de liste paginee serveur (Hydra), branche directement sur MalioDataTable - 22 tests Vitest (navigation, bornes, parse Hydra, hors-borne, reset, filtres, tri, swallow erreur) - Migration des pages admin existantes : sites, users, roles, categories - Refactor de useCategoriesAdmin pour ne porter que le referentiel CategoryType (charge en une fois via ?pagination=false) - Etat page/tri/filtre 100% local dans le composable (respect regle ABSOLUE n°6 — pas de persistance URL) - Section dediee dans .claude/rules/frontend.md documentant le pattern obligatoire pour toute nouvelle liste ERP-73 — volet front de la pagination, depend du back ERP-72 deja merge.
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
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('page hors borne apres filtre retombe sur la derniere page valide', async () => {
|
|
// 1er fetch : page 1 sur une grosse liste
|
|
mockResponse([], 50) // 5 pages
|
|
const list = usePaginatedList({ url: '/users' })
|
|
await list.fetch()
|
|
mockResponse([], 50)
|
|
await list.goToPage(5)
|
|
expect(list.currentPage.value).toBe(5)
|
|
|
|
// Application d'un filtre : la nouvelle reponse a 12 items
|
|
// (donc 2 pages) mais on demande page=5 → l'API renvoie member=[]
|
|
// et le composable doit refetcher sur page=2.
|
|
mockApiGet.mockReset()
|
|
mockApiGet
|
|
// 1er appel : page=5 alors qu'il ne reste que 2 pages
|
|
.mockResolvedValueOnce({ member: [], totalItems: 12 })
|
|
// 2eme appel : refetch automatique sur page=2
|
|
.mockResolvedValueOnce({ member: [{ id: 1 }, { id: 2 }], totalItems: 12 })
|
|
|
|
await list.setFilters({ active: true } as never)
|
|
|
|
// setFilters reset page a 1 → c'est le cas standard, pas le hors borne.
|
|
// Pour declencher le cas hors borne, on doit forcer la page > totalPages.
|
|
expect(list.currentPage.value).toBe(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 () => {
|
|
mockApiGet.mockRejectedValueOnce(new Error('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)
|
|
})
|
|
|
|
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 }])
|
|
})
|
|
})
|