Files
Coltura/frontend/shared/composables/useDataTableServerState.ts
tristan cb6d2d72ec feat(admin) : filtres + pagination serveur sur /admin/users/sites/roles
Ajoute le filtrage par colonne et la pagination negociee via query params
sur les 3 DataTables admin existantes. Tout est cote serveur (API Platform
SearchFilter + BooleanFilter) pour scaler naturellement.

Backend :
- api_platform.yaml : scan du mapping Sites + pagination_client_items_per_page
  (avec borne max 100 pour proteger contre les payloads exagerement grands).
- User : SearchFilter username (partial), rbacRoles.code (exact),
  sites.name (exact) + BooleanFilter isAdmin.
- Site : SearchFilter name/city/postalCode (partial).
- Role : SearchFilter label/code (partial), permissions.code (exact).
  (BooleanFilter isSystem deja present.)

Frontend :
- Composable useDataTableServerState (shared) : singleton de page/perPage/
  filters avec debounce 300ms sur les filters, fetch immediat sur page/
  perPage, reset page=1 au changement filter, token anti-race-condition.
- Pages admin : chaque filtre dans un slot #header-{key} (input text avec
  debounce, select mono-selection pour les relations). Font-size 20px sur
  les inputs de filtre.
- /admin/users : colonne Sites + filtre Sites conditionnes par
  useModules().isModuleActive('sites') — preserve l'invariant "module
  desactivable sans casse".

Tests : 215/215 PHPUnit (14 nouveaux filtres/pagination) + 48/48 Vitest
(8 nouveaux useDataTableServerState).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:00:34 +02:00

150 lines
5.1 KiB
TypeScript

import { ref, watch } from 'vue'
/**
* Composable generique pour les DataTables admin avec pagination, perPage
* et filtres cote serveur (API Platform + Hydra).
*
* Usage type dans une page admin :
*
* ```ts
* const { items, totalItems, page, perPage, filters, loading, reload } =
* useDataTableServerState<Site>('/sites', {
* name: '',
* city: '',
* postalCode: '',
* })
* ```
*
* Le composable :
* - traque `page`, `perPage`, et un objet `filters` reactif.
* - re-fetch automatiquement a chaque changement (debounce 300ms sur
* `filters` pour eviter un spam lors de la frappe clavier).
* - re-fetch immediat (pas de debounce) quand `page` ou `perPage` change
* — ces changements sont deja des clics user discrets.
* - reinitialise `page` a 1 des qu'un filtre bouge (coherence UX : un
* filtre ajuste ne doit pas laisser l'user sur "page 5 de 2 pages").
* - expose `loading` pour afficher un feedback pendant la requete.
* - expose `reload()` pour forcer un fetch (ex: apres une mutation
* POST/PATCH/DELETE).
*
* Type parameter T = la forme d'un item renvoye par l'API (le member[]
* du payload Hydra est type T[]).
*/
export function useDataTableServerState<T = Record<string, unknown>>(
endpoint: string,
initialFilters: Record<string, string | boolean | null> = {},
options: { debounceMs?: number, initialPerPage?: number } = {},
) {
const api = useApi()
const debounceMs = options.debounceMs ?? 300
const initialPerPage = options.initialPerPage ?? 10
const items = ref<T[]>([]) as { value: T[] }
const totalItems = ref(0)
const page = ref(1)
const perPage = ref(initialPerPage)
const filters = ref<Record<string, string | boolean | null>>({ ...initialFilters })
const loading = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
// Token de generation : chaque reload incremente ce compteur. Quand
// une reponse arrive, on verifie que son token est toujours le plus
// recent — sinon on ignore (protection anti race condition si l'user
// tape vite plusieurs filtres).
let requestToken = 0
/**
* Construit le payload query params pour useApi.get.
* Les filtres a valeur vide (chaine vide, null) sont omis pour eviter
* de filtrer sur "rien" (comportement API Platform : filtre present
* avec valeur vide = ne retourne aucun resultat).
*/
function buildQueryParams(): Record<string, string | number | boolean> {
const params: Record<string, string | number | boolean> = {
page: page.value,
itemsPerPage: perPage.value,
}
for (const [key, value] of Object.entries(filters.value)) {
if (value === '' || value === null) continue
params[key] = value as string | boolean
}
return params
}
async function fetchItems(): Promise<void> {
const currentToken = ++requestToken
loading.value = true
try {
const data = await api.get<{ member: T[], totalItems: number }>(
endpoint,
buildQueryParams(),
{ toast: false },
)
// Ignore si une requete plus recente a ete lancee entre-temps.
if (currentToken !== requestToken) return
// Defensive : un mock/test ou une API mal configuree peut
// renvoyer undefined. On ne crash pas, on laisse les valeurs
// par defaut.
items.value = data?.member ?? []
totalItems.value = data?.totalItems ?? 0
} finally {
if (currentToken === requestToken) {
loading.value = false
}
}
}
/**
* Force un refetch immediat, sans debounce. Utile apres une mutation
* (POST/PATCH/DELETE) ou au mount initial.
*/
function reload(): void {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
void fetchItems()
}
/**
* Programme un refetch debounced. Utilise par le watcher de `filters`.
*/
function scheduleReload(): void {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = null
void fetchItems()
}, debounceMs)
}
// Watcher sur page/perPage : refetch immediat (pas de spam possible,
// l'user clique sur un bouton pagination).
watch([page, perPage], () => {
reload()
})
// Watcher sur filters : refetch debounced + reset page a 1 pour
// eviter l'etat "filtre qui reduit le total mais user reste sur une
// page inexistante".
watch(filters, () => {
if (page.value !== 1) {
page.value = 1
// Le changement de page declenchera son propre watcher, qui
// appellera reload(). Pas besoin d'en programmer un.
return
}
scheduleReload()
}, { deep: true })
return {
items,
totalItems,
page,
perPage,
filters,
loading,
reload,
}
}