fix(front) : usePaginatedList — garde anti-reponse periemee, expose error, durcit buildQuery
- ajoute un jeton de sequence dans fetch() : ignore les reponses arrivees apres une requete plus recente (race page/tri/filtres sur reseau lent) - expose une ref error pour distinguer liste vide d'un echec reseau/403 (isEmpty ne suffit pas a lever l'ambiguite) - buildQuery : cles reservees (page/itemsPerPage/order) assignees en dernier pour qu'un filtre homonyme ne les ecrase pas - corrige le commentaire trompeur sur extraQuery (snapshot statique) - nettoie le test hors-borne qui testait en realite le cas standard + ajoute tests error/reset-error/race
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -63,8 +63,11 @@ export interface UsePaginatedListOptions<F extends PaginatedListFilters = Pagina
|
||||
defaultSort?: SortSpec | null
|
||||
/**
|
||||
* Query params additionnels propres a la ressource (ex : `includeDeleted=true`,
|
||||
* `groups[]=foo`) injectes a chaque requete. Reactifs si une ref / computed
|
||||
* est fournie via `refresh()` apres mutation.
|
||||
* `groups[]=foo`) injectes a chaque requete. **Snapshot statique** : l'objet
|
||||
* est lu tel quel a chaque `fetch()`, ses valeurs ne sont pas deballees. Ne
|
||||
* pas y passer de `ref` / `computed` (elles seraient serialisees comme objet,
|
||||
* pas comme valeur) — pour un extra reactif, muter les filtres via
|
||||
* `setFilters` ou ouvrir un ticket pour un support `MaybeRefOrGetter`.
|
||||
*/
|
||||
extraQuery?: Record<string, unknown>
|
||||
}
|
||||
@@ -84,6 +87,14 @@ export interface UsePaginatedListReturn<T, F extends PaginatedListFilters = Pagi
|
||||
totalPages: Ref<number>
|
||||
/** Indicateur de chargement (vrai pendant `fetch()`). */
|
||||
loading: Ref<boolean>
|
||||
/**
|
||||
* 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<unknown | null>
|
||||
/** Vrai apres au moins un fetch reussi avec 0 item. */
|
||||
isEmpty: Ref<boolean>
|
||||
/** Vrai si la collection tient en une seule page (totalPages <= 1). */
|
||||
@@ -150,6 +161,13 @@ export function usePaginatedList<T, F extends PaginatedListFilters = PaginatedLi
|
||||
const itemsPerPage = ref(defaultItemsPerPage)
|
||||
const itemsPerPageOptions = ref(options.itemsPerPageOptions ?? [10, 25, 50])
|
||||
const loading = ref(false)
|
||||
const error = ref<unknown | null>(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<T, F extends PaginatedListFilters = PaginatedLi
|
||||
const isSinglePage = computed(() => 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<string, unknown> {
|
||||
const query: Record<string, unknown> = {
|
||||
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<string, unknown> = {}
|
||||
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<T, F extends PaginatedListFilters = PaginatedLi
|
||||
* pour eviter une boucle si l'API renvoie des resultats incoherents.
|
||||
*/
|
||||
async function fetch(): Promise<void> {
|
||||
const token = ++fetchToken
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await api.get<HydraCollection<T>>(
|
||||
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<T, F extends PaginatedListFilters = PaginatedLi
|
||||
buildQuery(),
|
||||
{ toast: false, headers: JSONLD_HEADERS },
|
||||
)
|
||||
// Meme garde apres le refetch hors-borne.
|
||||
if (token !== fetchToken) return
|
||||
items.value = data2.member ?? []
|
||||
totalItems.value = data2.totalItems ?? 0
|
||||
}
|
||||
|
||||
hasFetched.value = true
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// Reponse periemee : ne pas toucher au state, une requete plus
|
||||
// recente est en cours et fera foi.
|
||||
if (token !== fetchToken) return
|
||||
// Swallow volontaire : on remet la liste a vide pour ne pas
|
||||
// afficher de donnees stale. Le composant parent decide de
|
||||
// l'UX (toast / message d'erreur) — pas d'a-priori ici.
|
||||
// afficher de donnees stale, et on expose l'erreur pour que la
|
||||
// page distingue « vide » d'« echec ». Le composant parent
|
||||
// decide de l'UX (toast / message d'erreur) — pas d'a-priori ici.
|
||||
error.value = e
|
||||
items.value = []
|
||||
totalItems.value = 0
|
||||
hasFetched.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
// Seule la requete la plus recente eteint le spinner : une
|
||||
// reponse periemee ne doit pas le couper alors qu'un fetch plus
|
||||
// recent est encore en vol.
|
||||
if (token === fetchToken) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +346,7 @@ export function usePaginatedList<T, F extends PaginatedListFilters = PaginatedLi
|
||||
itemsPerPageOptions,
|
||||
totalPages,
|
||||
loading,
|
||||
error,
|
||||
isEmpty,
|
||||
isSinglePage,
|
||||
filters,
|
||||
|
||||
Reference in New Issue
Block a user