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>
204 lines
6.6 KiB
TypeScript
204 lines
6.6 KiB
TypeScript
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 }])
|
|
})
|
|
})
|