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.
328 lines
13 KiB
TypeScript
328 lines
13 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. Reactifs si une ref / computed
|
|
* est fournie via `refresh()` apres mutation.
|
|
*/
|
|
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>
|
|
/** 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)
|
|
// `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 : pagination + tri + filtres +
|
|
* extras propres a la ressource. 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
|
|
}
|
|
if (options.extraQuery) {
|
|
Object.assign(query, options.extraQuery)
|
|
}
|
|
Object.assign(query, filters.value)
|
|
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> {
|
|
loading.value = true
|
|
try {
|
|
const data = await api.get<HydraCollection<T>>(
|
|
options.url,
|
|
buildQuery(),
|
|
{ toast: false, headers: JSONLD_HEADERS },
|
|
)
|
|
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 },
|
|
)
|
|
items.value = data2.member ?? []
|
|
totalItems.value = data2.totalItems ?? 0
|
|
}
|
|
|
|
hasFetched.value = true
|
|
} catch {
|
|
// 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.
|
|
items.value = []
|
|
totalItems.value = 0
|
|
hasFetched.value = true
|
|
} finally {
|
|
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,
|
|
isEmpty,
|
|
isSinglePage,
|
|
filters,
|
|
sort,
|
|
fetch,
|
|
goToPage,
|
|
nextPage,
|
|
prevPage,
|
|
setItemsPerPage,
|
|
setFilters,
|
|
setSort,
|
|
reset,
|
|
refresh: fetch,
|
|
}
|
|
}
|