diff --git a/frontend/shared/composables/__tests__/usePaginatedList.test.ts b/frontend/shared/composables/__tests__/usePaginatedList.test.ts index 02a5bb0..9e6b3c1 100644 --- a/frontend/shared/composables/__tests__/usePaginatedList.test.ts +++ b/frontend/shared/composables/__tests__/usePaginatedList.test.ts @@ -231,8 +231,11 @@ describe('usePaginatedList', () => { 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 + 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() @@ -240,21 +243,11 @@ describe('usePaginatedList', () => { 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 }) - + mockResponse([{ id: 1 }, { id: 2 }], 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) + expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1 }) }) it('declenche le retry sur derniere page si currentPage > totalPages apres fetch', async () => { @@ -332,7 +325,8 @@ describe('usePaginatedList', () => { }) it('swallow l\'erreur reseau : items vides, loading false, fetch ne reject pas', async () => { - mockApiGet.mockRejectedValueOnce(new Error('boom')) + const boom = new Error('boom') + mockApiGet.mockRejectedValueOnce(boom) const list = usePaginatedList({ url: '/users' }) await expect(list.fetch()).resolves.toBeUndefined() @@ -341,6 +335,47 @@ describe('usePaginatedList', () => { 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 () => { diff --git a/frontend/shared/composables/usePaginatedList.ts b/frontend/shared/composables/usePaginatedList.ts index c248218..9115363 100644 --- a/frontend/shared/composables/usePaginatedList.ts +++ b/frontend/shared/composables/usePaginatedList.ts @@ -63,8 +63,11 @@ export interface UsePaginatedListOptions } @@ -84,6 +87,14 @@ export interface UsePaginatedListReturn /** Indicateur de chargement (vrai pendant `fetch()`). */ loading: Ref + /** + * Derniere erreur de `fetch()` (null si le dernier appel a abouti). + * Permet a la page de distinguer « liste reellement vide » d'un echec + * reseau / 403 : sans ca, `isEmpty` confond les deux cas (la liste + * tombe a 0 item dans les deux situations). La page decide de l'UX + * (bandeau, bouton reessayer) — le composable ne toaste pas. + */ + error: Ref /** Vrai apres au moins un fetch reussi avec 0 item. */ isEmpty: Ref /** Vrai si la collection tient en une seule page (totalPages <= 1). */ @@ -150,6 +161,13 @@ export function usePaginatedList(null) + // Jeton de sequence : incremente a chaque `fetch()`. Une reponse dont + // le jeton n'est plus le dernier est ignoree (protection contre les + // reponses periemes quand l'utilisateur enchaine page / tri / filtres + // plus vite que le reseau ne repond — sinon la derniere reponse + // *arrivee* gagnerait au lieu de la derniere *demandee*). + let fetchToken = 0 // `hasFetched` evite que `isEmpty` retourne `true` avant le premier // chargement (etat initial = 0 items mais on ne sait pas encore si la // ressource est vide ou en cours de chargement). Un appel reseau au @@ -166,23 +184,26 @@ export function usePaginatedList totalPages.value <= 1) /** - * Construit l'objet query envoye a l'API : pagination + tri + filtres + - * extras propres a la ressource. Les filtres `null`/`undefined`/'' sont - * elimines pour ne pas polluer l'URL. + * Construit l'objet query envoye a l'API : extras + filtres, puis + * pagination + tri. Les cles reservees (`page`, `itemsPerPage`, + * `order[...]`) sont assignees **en dernier** pour qu'un filtre ou un + * extra portant le meme nom ne puisse pas ecraser silencieusement la + * pagination. Les filtres `null`/`undefined`/'' sont elimines pour ne + * pas polluer l'URL. */ function buildQuery(): Record { - const query: Record = { - page: currentPage.value, - itemsPerPage: itemsPerPage.value, - } - if (sort.value) { - // Format API Platform : ?order[field]=asc - query[`order[${sort.value.field}]`] = sort.value.direction - } + const query: Record = {} if (options.extraQuery) { Object.assign(query, options.extraQuery) } Object.assign(query, filters.value) + // Cles reservees en dernier : priorite a la pagination/au tri. + query.page = currentPage.value + query.itemsPerPage = itemsPerPage.value + if (sort.value) { + // Format API Platform : ?order[field]=asc + query[`order[${sort.value.field}]`] = sort.value.direction + } return compactQuery(query) } @@ -194,13 +215,18 @@ export function usePaginatedList { + const token = ++fetchToken loading.value = true + error.value = null try { const data = await api.get>( options.url, buildQuery(), { toast: false, headers: JSONLD_HEADERS }, ) + // Une requete plus recente a ete lancee entre-temps : on jette + // cette reponse pour ne pas ecraser des donnees plus fraiches. + if (token !== fetchToken) return items.value = data.member ?? [] totalItems.value = data.totalItems ?? 0 @@ -220,20 +246,30 @@ export function usePaginatedList