[ERP-73] Paginer toutes les listes côté front + composable de liste paginée réutilisable (#30)
Auto Tag Develop / tag (push) Successful in 8s
Auto Tag Develop / tag (push) Successful in 8s
## Contexte Ticket Lesstime : #73 (id 492) — volet front de la pagination (groupe Transversal). Dépend du back ERP-72 (déjà mergé sur develop). Pas de spec docs/specs ; référence = description #73 + .claude/rules/frontend.md. ## Implémentation - Composable réutilisable `usePaginatedList` (`frontend/shared/composables/`) générique, branché directement sur `MalioDataTable` (props page/perPage/totalItems + events update:page/update:per-page). - Force `Accept: application/ld+json` (sans Accept, API Platform renvoie un tableau plat sans pagination). - Migration des pages admin existantes (M0 catégories, Sites, Utilisateurs, Rôles) vers le composable. - Refactor de `useCategoriesAdmin` : ne porte plus la liste paginée (déplacée vers `usePaginatedList<Category>` dans la page) et concentre son rôle sur le référentiel `CategoryType` (chargé en une fois via `?pagination=false`, échappatoire prévue par `pagination_client_enabled: true` côté back). - Cas limites couverts : liste vide (pas de contrôle pagination affiché), page hors borne après filtre (retombe sur la dernière page valide), items/page 10/25/50, reset filtres/tri, swallow erreur réseau. - Pattern « liste paginée » documenté dans `.claude/rules/frontend.md` (section dédiée + exemple). ## Décision URL Le ticket suggérait « idéalement page/tri/filtre dans l'URL » — arbitré explicitement par Tristan en faveur de la règle ABSOLUE n°6 du CLAUDE.md (state local uniquement, jamais persisté dans l'URL). Aucun reflet URL implémenté ; comportement homogène entre toutes les listes migrées. ## Tests - `make nuxt-test` : 101/101 OK (22 nouveaux tests sur `usePaginatedList`, 6 anciens tests `useCategoriesAdmin.fetchAll` retirés en cohérence avec la refacto). - Vérification manuelle dans le navigateur (`make dev-nuxt`) : Sites, Utilisateurs, Rôles, Catégories affichent le sélecteur `Lignes : 10` et les boutons Prev/Next ; audit-log (non migré, composable spécifique) intact avec ses 3 pages. - Aucun test E2E ajouté (règle d'or projet). - Pre-commit hook : ESLint + PHPUnit 322/322 OK. ## Hors périmètre - `audit-log.vue` non migré : composable `useAuditLog` spécifique (cache partagé page/timeline, filtres complexes, persistance URL préexistante). Refactor risqué et net-zéro pour ERP-73. - M1 répertoire clients : pas encore livré sur develop (seules les specs sont mergées via #23). Le futur écran consommera `usePaginatedList` dès sa création. Reviewed-on: #30 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #30.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||
import type { CategoryType } from '~/modules/catalog/types/category'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
|
||||
// Mock du store auth : useCategoriesAdmin s'auto-enregistre via
|
||||
@@ -28,27 +28,6 @@ const { useCategoriesAdmin } = await import('../useCategoriesAdmin')
|
||||
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
|
||||
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
||||
|
||||
const CAT_A: Category = {
|
||||
id: 10,
|
||||
name: 'Vis',
|
||||
categoryType: TYPE_VENTE,
|
||||
deletedAt: null,
|
||||
createdAt: '2026-01-01T10:00:00+00:00',
|
||||
updatedAt: '2026-01-01T10:00:00+00:00',
|
||||
createdBy: null,
|
||||
updatedBy: null,
|
||||
}
|
||||
const CAT_B: Category = {
|
||||
id: 11,
|
||||
name: 'Boulons',
|
||||
categoryType: TYPE_VENTE,
|
||||
deletedAt: null,
|
||||
createdAt: '2026-01-02T10:00:00+00:00',
|
||||
updatedAt: '2026-01-02T10:00:00+00:00',
|
||||
createdBy: null,
|
||||
updatedBy: null,
|
||||
}
|
||||
|
||||
function makeHydra<T>(items: T[]): HydraCollection<T> {
|
||||
return {
|
||||
totalItems: items.length,
|
||||
@@ -56,113 +35,32 @@ function makeHydra<T>(items: T[]): HydraCollection<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apres ERP-73, `useCategoriesAdmin` ne porte plus la liste paginee des
|
||||
* categories (elle est geree par `usePaginatedList<Category>` cote page).
|
||||
* Le composable se concentre sur le referentiel CategoryType (lecture
|
||||
* seule, ≤ 5 entrees connues) charge en une fois via `?pagination=false`.
|
||||
*/
|
||||
describe('useCategoriesAdmin', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
// Reset systematique du state singleton entre tests : sans ca,
|
||||
// les categories chargees dans un test fuiteraient dans le suivant.
|
||||
// les types charges dans un test fuiteraient dans le suivant.
|
||||
const { resetCategoriesAdmin } = useCategoriesAdmin()
|
||||
resetCategoriesAdmin()
|
||||
})
|
||||
|
||||
describe('fetchAll', () => {
|
||||
it('appelle GET /categories avec itemsPerPage=999 par defaut', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
|
||||
const { fetchAll } = useCategoriesAdmin()
|
||||
|
||||
await fetchAll()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/categories',
|
||||
{ itemsPerPage: 999 },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('peuple categories.value depuis le champ Hydra member', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra([CAT_A, CAT_B]))
|
||||
const { fetchAll, categories } = useCategoriesAdmin()
|
||||
|
||||
await fetchAll()
|
||||
|
||||
expect(categories.value).toEqual([CAT_A, CAT_B])
|
||||
})
|
||||
|
||||
it('exclut les soft-deleted par defaut (pas de query includeDeleted)', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
|
||||
const { fetchAll } = useCategoriesAdmin()
|
||||
|
||||
await fetchAll()
|
||||
|
||||
const queryArg = mockGet.mock.calls[0]?.[1] as Record<string, unknown>
|
||||
expect(queryArg).not.toHaveProperty('includeDeleted')
|
||||
})
|
||||
|
||||
it('ajoute includeDeleted=true quand demande explicitement', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
|
||||
const { fetchAll } = useCategoriesAdmin()
|
||||
|
||||
await fetchAll(true)
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/categories',
|
||||
{ itemsPerPage: 999, includeDeleted: 'true' },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('passe loading a true pendant la requete et false apres', async () => {
|
||||
let resolveRequest: (v: HydraCollection<Category>) => void = () => {}
|
||||
mockGet.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||
)
|
||||
const { fetchAll, loading } = useCategoriesAdmin()
|
||||
|
||||
const pending = fetchAll()
|
||||
expect(loading.value).toBe(true)
|
||||
|
||||
resolveRequest(makeHydra<Category>([]))
|
||||
await pending
|
||||
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('peuple error.value et vide categories en cas d echec', async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error('Network down'))
|
||||
const { fetchAll, categories, error, loading } = useCategoriesAdmin()
|
||||
// Pre-charge volontairement quelque chose pour verifier la purge.
|
||||
categories.value = [CAT_A]
|
||||
|
||||
await fetchAll()
|
||||
|
||||
expect(categories.value).toEqual([])
|
||||
expect(error.value).toBe('Network down')
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('gere une reponse sans champ member (fallback tableau vide)', async () => {
|
||||
mockGet.mockResolvedValueOnce({
|
||||
totalItems: 0,
|
||||
} as unknown as HydraCollection<Category>)
|
||||
const { fetchAll, categories } = useCategoriesAdmin()
|
||||
|
||||
await fetchAll()
|
||||
|
||||
expect(categories.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchTypes', () => {
|
||||
it('appelle GET /category_types avec itemsPerPage=999', async () => {
|
||||
it('appelle GET /category_types avec ?pagination=false (echappatoire selects)', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([]))
|
||||
const { fetchTypes } = useCategoriesAdmin()
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/category_types',
|
||||
{ itemsPerPage: 999 },
|
||||
{ pagination: 'false' },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
@@ -203,48 +101,55 @@ describe('useCategoriesAdmin', () => {
|
||||
|
||||
expect(loadingTypes.value).toBe(false)
|
||||
})
|
||||
|
||||
it('gere une reponse sans champ member (fallback tableau vide)', async () => {
|
||||
mockGet.mockResolvedValueOnce({
|
||||
totalItems: 0,
|
||||
} as unknown as HydraCollection<CategoryType>)
|
||||
const { fetchTypes, types } = useCategoriesAdmin()
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(types.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetCategoriesAdmin', () => {
|
||||
it('vide categories, types, loading, loadingTypes et error', () => {
|
||||
const { resetCategoriesAdmin, categories, types, loading, loadingTypes, error }
|
||||
it('vide types, loadingTypes et error', () => {
|
||||
const { resetCategoriesAdmin, types, loadingTypes, error }
|
||||
= useCategoriesAdmin()
|
||||
// Pre-peuple le state pour verifier la purge effective.
|
||||
categories.value = [CAT_A]
|
||||
types.value = [TYPE_VENTE]
|
||||
loading.value = true
|
||||
loadingTypes.value = true
|
||||
error.value = 'oops'
|
||||
|
||||
resetCategoriesAdmin()
|
||||
|
||||
expect(categories.value).toEqual([])
|
||||
expect(types.value).toEqual([])
|
||||
expect(loading.value).toBe(false)
|
||||
expect(loadingTypes.value).toBe(false)
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('singleton', () => {
|
||||
it('deux appels a useCategoriesAdmin() partagent la meme ref categories', () => {
|
||||
it('deux appels a useCategoriesAdmin() partagent la meme ref types', () => {
|
||||
const a = useCategoriesAdmin()
|
||||
const b = useCategoriesAdmin()
|
||||
|
||||
// Les fonctions sont reinstanciees a chaque appel mais les refs
|
||||
// doivent etre rigoureusement les memes (state au niveau module).
|
||||
expect(a.categories).toBe(b.categories)
|
||||
expect(a.types).toBe(b.types)
|
||||
expect(a.loading).toBe(b.loading)
|
||||
expect(a.loadingTypes).toBe(b.loadingTypes)
|
||||
expect(a.error).toBe(b.error)
|
||||
})
|
||||
|
||||
it('une mutation via une instance est visible depuis une autre instance', () => {
|
||||
const a = useCategoriesAdmin()
|
||||
const b = useCategoriesAdmin()
|
||||
|
||||
a.categories.value = [CAT_A]
|
||||
a.types.value = [TYPE_VENTE]
|
||||
|
||||
expect(b.categories.value).toEqual([CAT_A])
|
||||
expect(b.types.value).toEqual([TYPE_VENTE])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user