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 export interface UsePaginatedListOptions { /** 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 } export interface UsePaginatedListReturn { /** Items de la page courante. */ items: Ref /** Total d'items (toutes pages) renvoye par Hydra. */ totalItems: Ref /** Page courante (1-based). */ currentPage: Ref /** Taille de page courante. */ itemsPerPage: Ref /** Options exposees au selecteur items/page. */ itemsPerPageOptions: Ref /** Nombre total de pages (≥ 1). */ totalPages: Ref /** Indicateur de chargement (vrai pendant `fetch()`). */ loading: Ref /** Vrai apres au moins un fetch reussi avec 0 item. */ isEmpty: Ref /** Vrai si la collection tient en une seule page (totalPages <= 1). */ isSinglePage: Ref /** Filtres courants (mutation via `setFilters`). */ filters: Ref /** Tri courant (mutation via `setSort`). */ sort: Ref /** Lance un fetch contre l'API et met a jour items/totalItems. */ fetch: () => Promise /** Va a la page demandee (bornes appliquees : 1 ≤ p ≤ totalPages). */ goToPage: (page: number) => Promise /** Page suivante (no-op si deja en derniere page). */ nextPage: () => Promise /** Page precedente (no-op si deja en premiere page). */ prevPage: () => Promise /** Change la taille de page et revient en page 1. */ setItemsPerPage: (value: number) => Promise /** Applique de nouveaux filtres et revient en page 1. */ setFilters: (next: Partial, options?: { replace?: boolean }) => Promise /** Change le tri et revient en page 1. */ setSort: (next: SortSpec | null) => Promise /** Reinitialise filtres + tri + page sur les valeurs par defaut. */ reset: () => Promise /** Alias de `fetch()` (intention plus claire dans certains contextes). */ refresh: () => Promise } /** * 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): Record { const out: Record = {} 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( options: UsePaginatedListOptions, ): UsePaginatedListReturn { const api = useApi() const defaultItemsPerPage = options.defaultItemsPerPage ?? 10 const initialFilters = { ...(options.defaultFilters ?? ({} as F)) } as F const initialSort = options.defaultSort ?? null const items = ref([]) as Ref 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 const sort = ref(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 { 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 } 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 { loading.value = true try { const data = await api.get>( 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>( 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 { 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 { if (currentPage.value >= totalPages.value) return await goToPage(currentPage.value + 1) } async function prevPage(): Promise { if (currentPage.value <= 1) return await goToPage(currentPage.value - 1) } async function setItemsPerPage(value: number): Promise { 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, opts?: { replace?: boolean }): Promise { 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)[key] } } filters.value = merged } currentPage.value = 1 await fetch() } async function setSort(next: SortSpec | null): Promise { sort.value = next ? { ...next } : null currentPage.value = 1 await fetch() } async function reset(): Promise { 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, } }