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>
150 lines
5.1 KiB
TypeScript
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,
|
|
}
|
|
}
|