Files
Starseed/frontend/shared/composables/usePaginatedList.ts
T
tristan ad20d1f4c9
Auto Tag Develop / tag (push) Successful in 8s
[ERP-73] Paginer toutes les listes côté front + composable de liste paginée réutilisable (#30)
## 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>
2026-06-01 09:54:54 +00:00

365 lines
15 KiB
TypeScript

import { computed, ref, type Ref } from 'vue'
import type { HydraCollection } from '~/shared/utils/api'
/**
* Composable generique de liste paginee serveur.
*
* Responsabilites :
* - centraliser l'etat tableau (page courante, items/page, tri, filtres,
* totalItems, items, loading, error) cote local — JAMAIS dans l'URL,
* conformement a la regle ABSOLUE n°6 du CLAUDE.md (« Jamais persister
* l'etat de tableau dans l'URL »).
* - dialoguer avec une ressource API Platform 4 (Hydra) en passant
* `page`, `itemsPerPage` et le tri/filtres en query params.
* - exposer une API simple a brancher sur `MalioDataTable`
* (props page/perPage/totalItems + events update:page / update:per-page).
*
* Volontairement **par-instance** (state local a chaque appel) : a la
* difference de `useAuditLog` / `useCategoriesAdmin` qui sont des
* singletons module-level partages, une liste paginee est propre a son
* ecran et ne doit pas etre partagee entre pages (sinon un retour
* arriere reprendrait la pagination d'une autre liste).
*
* Pas de gestion URL : si une page veut un deep link (ex : ouvrir un
* detail), elle le fait via sa propre route, pas via la query string
* de pagination. Derogation possible uniquement si l'utilisateur le
* demande explicitement, cf. CLAUDE.md.
*/
/**
* Direction de tri serveur. API Platform 4 attend `asc` ou `desc` via la
* syntaxe `?order[field]=asc`.
*/
export type SortDirection = 'asc' | 'desc'
/**
* Specification de tri : un seul champ trie a la fois cote front (la
* majorite des tableaux Malio n'expose pas le multi-tri). Si null, aucun
* `order[...]` n'est envoye et l'API applique son tri par defaut.
*/
export interface SortSpec {
field: string
direction: SortDirection
}
/**
* Type des filtres : un dictionnaire de valeurs serialisables en query
* params. Le caller decide du mapping (ex : `{ active: true }`,
* `{ 'name[ilike]': 'a' }`). Valeurs `null` / `undefined` / chaines vides
* sont automatiquement omises au moment de la requete.
*/
export type PaginatedListFilters = Record<string, string | number | boolean | string[] | null | undefined>
export interface UsePaginatedListOptions<F extends PaginatedListFilters = PaginatedListFilters> {
/** URL relative au prefix `/api` (ex : `/sites`, `/categories`). */
url: string
/** Items par page initial. Defaut 10 (aligne avec le defaut serveur). */
defaultItemsPerPage?: number
/** Options proposees dans le selecteur items/page. Defaut [10, 25, 50]. */
itemsPerPageOptions?: number[]
/** Filtres initiaux. */
defaultFilters?: F
/** Tri initial. */
defaultSort?: SortSpec | null
/**
* Query params additionnels propres a la ressource (ex : `includeDeleted=true`,
* `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>
}
export interface UsePaginatedListReturn<T, F extends PaginatedListFilters = PaginatedListFilters> {
/** Items de la page courante. */
items: Ref<T[]>
/** Total d'items (toutes pages) renvoye par Hydra. */
totalItems: Ref<number>
/** Page courante (1-based). */
currentPage: Ref<number>
/** Taille de page courante. */
itemsPerPage: Ref<number>
/** Options exposees au selecteur items/page. */
itemsPerPageOptions: Ref<number[]>
/** Nombre total de pages (≥ 1). */
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). */
isSinglePage: Ref<boolean>
/** Filtres courants (mutation via `setFilters`). */
filters: Ref<F>
/** Tri courant (mutation via `setSort`). */
sort: Ref<SortSpec | null>
/** Lance un fetch contre l'API et met a jour items/totalItems. */
fetch: () => Promise<void>
/** Va a la page demandee (bornes appliquees : 1 ≤ p ≤ totalPages). */
goToPage: (page: number) => Promise<void>
/** Page suivante (no-op si deja en derniere page). */
nextPage: () => Promise<void>
/** Page precedente (no-op si deja en premiere page). */
prevPage: () => Promise<void>
/** Change la taille de page et revient en page 1. */
setItemsPerPage: (value: number) => Promise<void>
/** Applique de nouveaux filtres et revient en page 1. */
setFilters: (next: Partial<F>, options?: { replace?: boolean }) => Promise<void>
/** Change le tri et revient en page 1. */
setSort: (next: SortSpec | null) => Promise<void>
/** Reinitialise filtres + tri + page sur les valeurs par defaut. */
reset: () => Promise<void>
/** Alias de `fetch()` (intention plus claire dans certains contextes). */
refresh: () => Promise<void>
}
/**
* Force `application/ld+json` : sous `application/json`, API Platform 4
* renvoie un tableau plat sans envelope de pagination — on ne pourrait pas
* lire `totalItems` ni `view`. Voir aussi `useAuditLog.ts`.
*/
const JSONLD_HEADERS = { Accept: 'application/ld+json' } as const
/**
* Filtre les entrees nulles/undefined/vides d'un objet de query : evite
* d'envoyer `?foo=&bar=null` a l'API qui declencherait parfois des erreurs
* de filtre cote Symfony (`FilterInterface::apply` strict).
*/
function compactQuery(raw: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [key, value] of Object.entries(raw)) {
if (value === null || value === undefined) continue
if (typeof value === 'string' && value === '') continue
if (Array.isArray(value) && value.length === 0) continue
out[key] = value
}
return out
}
export function usePaginatedList<T, F extends PaginatedListFilters = PaginatedListFilters>(
options: UsePaginatedListOptions<F>,
): UsePaginatedListReturn<T, F> {
const api = useApi()
const defaultItemsPerPage = options.defaultItemsPerPage ?? 10
const initialFilters = { ...(options.defaultFilters ?? ({} as F)) } as F
const initialSort = options.defaultSort ?? null
const items = ref<T[]>([]) as Ref<T[]>
const totalItems = ref(0)
const currentPage = ref(1)
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
// moins doit avoir abouti pour qu'on annonce une liste « vide ».
const hasFetched = ref(false)
const filters = ref({ ...initialFilters }) as Ref<F>
const sort = ref<SortSpec | null>(initialSort ? { ...initialSort } : null)
const totalPages = computed(() => {
if (totalItems.value <= 0 || itemsPerPage.value <= 0) return 1
return Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value))
})
const isEmpty = computed(() => hasFetched.value && totalItems.value === 0)
const isSinglePage = computed(() => totalPages.value <= 1)
/**
* 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> = {}
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)
}
/**
* Lance un fetch et applique la borne haute si necessaire. Si la page
* courante depasse `totalPages` apres l'application des filtres (cas
* « j'etais en page 5, je filtre, il ne reste qu'une page »), on
* rappelle l'API sur la derniere page valide. Un seul niveau de retry
* 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
const tp = totalItems.value > 0
? Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value))
: 1
// Si on est hors borne ET qu'il y a au moins une page valide
// a viser, on retombe sur la derniere page (cf. cas limite
// « page hors borne apres filtre » de la spec #73). On ne
// refetch que si la nouvelle page est differente, sinon
// boucle infinie potentielle.
if (currentPage.value > tp && tp >= 1 && totalItems.value > 0) {
currentPage.value = tp
const data2 = await api.get<HydraCollection<T>>(
options.url,
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 (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, 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 {
// 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
}
}
async function goToPage(page: number): Promise<void> {
const tp = totalPages.value
const next = Math.max(1, Math.min(page, tp))
if (next === currentPage.value) return
currentPage.value = next
await fetch()
}
async function nextPage(): Promise<void> {
if (currentPage.value >= totalPages.value) return
await goToPage(currentPage.value + 1)
}
async function prevPage(): Promise<void> {
if (currentPage.value <= 1) return
await goToPage(currentPage.value - 1)
}
async function setItemsPerPage(value: number): Promise<void> {
if (!Number.isFinite(value) || value <= 0) return
const rounded = Math.floor(value)
if (rounded === itemsPerPage.value) return
itemsPerPage.value = rounded
currentPage.value = 1
await fetch()
}
/**
* `replace: false` (defaut) fusionne avec les filtres courants. Une
* valeur explicitement `undefined` retire la cle (utile pour effacer
* un filtre depuis un champ controle). `replace: true` remplace
* integralement l'objet par `next`.
*/
async function setFilters(next: Partial<F>, opts?: { replace?: boolean }): Promise<void> {
if (opts?.replace) {
filters.value = { ...(next as F) }
} else {
const merged = { ...filters.value, ...next } as F
// Supprime les cles explicitement passees a undefined : sans ce
// nettoyage, l'objet `filters` accumulerait des cles fantomes.
for (const key of Object.keys(next)) {
if (next[key as keyof F] === undefined) {
delete (merged as Record<string, unknown>)[key]
}
}
filters.value = merged
}
currentPage.value = 1
await fetch()
}
async function setSort(next: SortSpec | null): Promise<void> {
sort.value = next ? { ...next } : null
currentPage.value = 1
await fetch()
}
async function reset(): Promise<void> {
filters.value = { ...initialFilters }
sort.value = initialSort ? { ...initialSort } : null
itemsPerPage.value = defaultItemsPerPage
currentPage.value = 1
await fetch()
}
return {
items,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
totalPages,
loading,
error,
isEmpty,
isSinglePage,
filters,
sort,
fetch,
goToPage,
nextPage,
prevPage,
setItemsPerPage,
setFilters,
setSort,
reset,
refresh: fetch,
}
}