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:
@@ -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 }])
|
||||
})
|
||||
})
|
||||
149
frontend/shared/composables/useDataTableServerState.ts
Normal file
149
frontend/shared/composables/useDataTableServerState.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user