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>
This commit is contained in:
2026-04-20 17:00:34 +02:00
parent 296befe187
commit cb6d2d72ec
10 changed files with 875 additions and 88 deletions

View File

@@ -0,0 +1,203 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { nextTick } from 'vue'
import { useDataTableServerState } from '../useDataTableServerState'
const mockApiGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
function ldResponse<T>(member: T[], totalItems?: number): { member: T[], totalItems: number } {
return { member, totalItems: totalItems ?? member.length }
}
describe('useDataTableServerState', () => {
beforeEach(() => {
mockApiGet.mockReset()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('fetch initial au premier reload() avec page=1 et perPage par defaut', async () => {
mockApiGet.mockResolvedValueOnce(ldResponse([{ id: 1 }, { id: 2 }], 42))
const { items, totalItems, reload } = useDataTableServerState('/sites', { name: '' })
reload()
await vi.runAllTimersAsync()
expect(mockApiGet).toHaveBeenCalledWith(
'/sites',
{ page: 1, itemsPerPage: 10 },
{ toast: false },
)
expect(items.value).toHaveLength(2)
expect(totalItems.value).toBe(42)
})
it('omet les filtres a valeur vide dans les query params', async () => {
mockApiGet.mockResolvedValueOnce(ldResponse([]))
const { reload } = useDataTableServerState('/users', {
username: '',
isAdmin: null,
})
reload()
await vi.runAllTimersAsync()
expect(mockApiGet).toHaveBeenCalledWith(
'/users',
{ page: 1, itemsPerPage: 10 },
{ toast: false },
)
})
it('inclut les filtres renseignes dans les query params', async () => {
// mockResolvedValue (sans Once) : chaque fetch retourne une
// reponse valide, y compris ceux declenches par le debounce des
// mutations de filters qui precedent reload().
mockApiGet.mockResolvedValue(ldResponse([]))
const { filters, reload } = useDataTableServerState('/users', {
username: '',
isAdmin: null,
})
filters.value.username = 'alice'
filters.value.isAdmin = true
reload()
await vi.runAllTimersAsync()
// Le reload() ecrase les scheduleReload en cours (clearTimeout),
// donc on verifie juste que la derniere requete emise porte bien
// les filtres + les parametres de pagination.
expect(mockApiGet).toHaveBeenLastCalledWith(
'/users',
{ page: 1, itemsPerPage: 10, username: 'alice', isAdmin: true },
{ toast: false },
)
})
it('change page declenche un fetch immediat (pas de debounce)', async () => {
mockApiGet.mockResolvedValue(ldResponse([]))
const { page, reload } = useDataTableServerState('/sites', {})
reload()
await vi.runAllTimersAsync()
expect(mockApiGet).toHaveBeenCalledTimes(1)
page.value = 3
await nextTick()
await vi.runAllTimersAsync()
expect(mockApiGet).toHaveBeenCalledTimes(2)
expect(mockApiGet).toHaveBeenLastCalledWith(
'/sites',
{ page: 3, itemsPerPage: 10 },
{ toast: false },
)
})
it('change filter debounce 300ms avant fetch', async () => {
mockApiGet.mockResolvedValue(ldResponse([]))
const { filters, reload } = useDataTableServerState('/sites', { name: '' })
reload()
await vi.runAllTimersAsync()
mockApiGet.mockClear()
filters.value.name = 'a'
await nextTick()
// Pas encore de requete : debounce en cours.
expect(mockApiGet).not.toHaveBeenCalled()
filters.value.name = 'al'
await nextTick()
filters.value.name = 'ali'
await nextTick()
// Avance le timer de 200ms : toujours pas fetch.
vi.advanceTimersByTime(200)
expect(mockApiGet).not.toHaveBeenCalled()
// Avance encore 100ms : debounce expire, fetch lance.
vi.advanceTimersByTime(100)
await vi.runAllTimersAsync()
expect(mockApiGet).toHaveBeenCalledTimes(1)
expect(mockApiGet).toHaveBeenCalledWith(
'/sites',
{ page: 1, itemsPerPage: 10, name: 'ali' },
{ toast: false },
)
})
it('changer un filtre reset page a 1', async () => {
mockApiGet.mockResolvedValue(ldResponse([]))
const { page, filters, reload } = useDataTableServerState('/sites', { name: '' })
reload()
await vi.runAllTimersAsync()
page.value = 5
await vi.runAllTimersAsync()
mockApiGet.mockClear()
filters.value.name = 'x'
await nextTick()
await vi.runAllTimersAsync()
// Page doit etre revenue a 1 avant le fetch.
expect(page.value).toBe(1)
expect(mockApiGet).toHaveBeenLastCalledWith(
'/sites',
expect.objectContaining({ page: 1, name: 'x' }),
{ toast: false },
)
})
it('change perPage declenche un fetch immediat', async () => {
mockApiGet.mockResolvedValue(ldResponse([]))
const { perPage, reload } = useDataTableServerState('/sites', {})
reload()
await vi.runAllTimersAsync()
mockApiGet.mockClear()
perPage.value = 25
await nextTick()
await vi.runAllTimersAsync()
expect(mockApiGet).toHaveBeenLastCalledWith(
'/sites',
{ page: 1, itemsPerPage: 25 },
{ toast: false },
)
})
it('race condition : seule la derniere reponse gagne', async () => {
// Scenario : user tape tres vite, 2 requetes partent, la premiere
// (plus ancienne) arrive apres la seconde. Le composable doit
// ignorer la premiere.
let resolveFirst!: (value: unknown) => void
let resolveSecond!: (value: unknown) => void
mockApiGet
.mockImplementationOnce(() => new Promise((r) => { resolveFirst = r }))
.mockImplementationOnce(() => new Promise((r) => { resolveSecond = r }))
const { items, reload } = useDataTableServerState<{ id: number }>('/sites', {})
reload() // requete #1
reload() // requete #2 (annule #1 du point de vue du token)
// Resout la seconde d'abord avec id=2
resolveSecond(ldResponse([{ id: 2 }]))
await vi.runAllTimersAsync()
expect(items.value).toEqual([{ id: 2 }])
// Resout la premiere apres avec id=1 : DOIT etre ignore.
resolveFirst(ldResponse([{ id: 1 }]))
await vi.runAllTimersAsync()
expect(items.value).toEqual([{ id: 2 }])
})
})

View File

@@ -0,0 +1,149 @@
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,
}
}