feat(front) : add usePaginatedList composable + paginate all admin lists via MalioDataTable
- frontend/shared/composables/usePaginatedList.ts : composable generique de liste paginee serveur (Hydra), branche directement sur MalioDataTable - 22 tests Vitest (navigation, bornes, parse Hydra, hors-borne, reset, filtres, tri, swallow erreur) - Migration des pages admin existantes : sites, users, roles, categories - Refactor de useCategoriesAdmin pour ne porter que le referentiel CategoryType (charge en une fois via ?pagination=false) - Etat page/tri/filtre 100% local dans le composable (respect regle ABSOLUE n°6 — pas de persistance URL) - Section dediee dans .claude/rules/frontend.md documentant le pattern obligatoire pour toute nouvelle liste ERP-73 — volet front de la pagination, depend du back ERP-72 deja merge.
This commit is contained in:
@@ -53,6 +53,53 @@ Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer
|
|||||||
|
|
||||||
**Exception** : tableaux purement presentationnels non paginables (diff field/old/new, grille de comparaison, matrice RBAC d'admin, etc.) peuvent rester en `<table>` HTML brut.
|
**Exception** : tableaux purement presentationnels non paginables (diff field/old/new, grille de comparaison, matrice RBAC d'admin, etc.) peuvent rester en `<table>` HTML brut.
|
||||||
|
|
||||||
|
## Listes paginees (standard) — usePaginatedList obligatoire
|
||||||
|
|
||||||
|
**Toute liste qui consomme une `GetCollection` API doit passer par `usePaginatedList`** (`frontend/shared/composables/usePaginatedList.ts`). Le composable est le pendant front de la regle ABSOLUE n°13 (« toute collection est paginee cote back ») : il consomme l'envelope Hydra (`member` / `totalItems` / `view`) et expose un etat reactif a brancher directement sur `MalioDataTable`.
|
||||||
|
|
||||||
|
Pattern de reference :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadList,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
} = usePaginatedList<MyEntity>({ url: '/my-resources' })
|
||||||
|
|
||||||
|
onMounted(loadList)
|
||||||
|
```
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="rows"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
|
:empty-message="t('foo.empty')"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Garanties offertes par le composable :
|
||||||
|
- Force `Accept: application/ld+json` → API Platform 4 renvoie bien `member` / `totalItems` (sans Accept, retour tableau plat sans pagination).
|
||||||
|
- Defaut 10 items/page, choix client 10 / 25 / 50, aligne sur le defaut serveur.
|
||||||
|
- Mutation `setFilters` / `setSort` / `setItemsPerPage` → retombe systematiquement en page 1.
|
||||||
|
- Cas limite « page hors borne apres filtre » : retombe automatiquement sur la derniere page valide (`tests/usePaginatedList.test.ts`).
|
||||||
|
- Etat 100 % local (refs internes a l'instance) — **jamais reflete dans l'URL**, conformement a la regle « Etat des tableaux — pas de persistance URL » ci-dessous.
|
||||||
|
|
||||||
|
A NE PAS faire :
|
||||||
|
- Charger une collection complete via `?itemsPerPage=999` pour bypasser la pagination. Le seul cas legitime de retour complet est l'alimentation d'un `<select>` sur un referentiel ≤ quelques dizaines d'entrees, et il passe par `?pagination=false` (echappatoire prevue par `pagination_client_enabled: true`).
|
||||||
|
- Reimplementer la pagination prev/next a la main au-dessus de `MalioDataTable` — le composant porte deja le selecteur items/page et les boutons Prev/Next.
|
||||||
|
- Persister `page`/`tri`/`filtre` dans la query string — meme regle que pour `<MalioDataTable>` brut (cf. section suivante).
|
||||||
|
|
||||||
## Etat des tableaux — pas de persistance URL
|
## Etat des tableaux — pas de persistance URL
|
||||||
|
|
||||||
**Interdit** de persister l'etat d'un tableau (filtres, pagination, tri par colonne, selection, ligne active, scroll) dans la query string ou de le reinjecter depuis `route.query` au montage.
|
**Interdit** de persister l'etat d'un tableau (filtres, pagination, tri par colonne, selection, ligne active, scroll) dans la query string ou de le reinjecter depuis `route.query` au montage.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
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'
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
|
|
||||||
// Mock du store auth : useCategoriesAdmin s'auto-enregistre via
|
// 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_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
|
||||||
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
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> {
|
function makeHydra<T>(items: T[]): HydraCollection<T> {
|
||||||
return {
|
return {
|
||||||
totalItems: items.length,
|
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', () => {
|
describe('useCategoriesAdmin', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGet.mockReset()
|
mockGet.mockReset()
|
||||||
// Reset systematique du state singleton entre tests : sans ca,
|
// 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()
|
const { resetCategoriesAdmin } = useCategoriesAdmin()
|
||||||
resetCategoriesAdmin()
|
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', () => {
|
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>([]))
|
mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([]))
|
||||||
const { fetchTypes } = useCategoriesAdmin()
|
const { fetchTypes } = useCategoriesAdmin()
|
||||||
|
|
||||||
await fetchTypes()
|
await fetchTypes()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||||
expect(mockGet).toHaveBeenCalledWith(
|
expect(mockGet).toHaveBeenCalledWith(
|
||||||
'/category_types',
|
'/category_types',
|
||||||
{ itemsPerPage: 999 },
|
{ pagination: 'false' },
|
||||||
{ toast: false },
|
{ toast: false },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -203,48 +101,55 @@ describe('useCategoriesAdmin', () => {
|
|||||||
|
|
||||||
expect(loadingTypes.value).toBe(false)
|
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', () => {
|
describe('resetCategoriesAdmin', () => {
|
||||||
it('vide categories, types, loading, loadingTypes et error', () => {
|
it('vide types, loadingTypes et error', () => {
|
||||||
const { resetCategoriesAdmin, categories, types, loading, loadingTypes, error }
|
const { resetCategoriesAdmin, types, loadingTypes, error }
|
||||||
= useCategoriesAdmin()
|
= useCategoriesAdmin()
|
||||||
// Pre-peuple le state pour verifier la purge effective.
|
// Pre-peuple le state pour verifier la purge effective.
|
||||||
categories.value = [CAT_A]
|
|
||||||
types.value = [TYPE_VENTE]
|
types.value = [TYPE_VENTE]
|
||||||
loading.value = true
|
|
||||||
loadingTypes.value = true
|
loadingTypes.value = true
|
||||||
error.value = 'oops'
|
error.value = 'oops'
|
||||||
|
|
||||||
resetCategoriesAdmin()
|
resetCategoriesAdmin()
|
||||||
|
|
||||||
expect(categories.value).toEqual([])
|
|
||||||
expect(types.value).toEqual([])
|
expect(types.value).toEqual([])
|
||||||
expect(loading.value).toBe(false)
|
|
||||||
expect(loadingTypes.value).toBe(false)
|
expect(loadingTypes.value).toBe(false)
|
||||||
expect(error.value).toBeNull()
|
expect(error.value).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('singleton', () => {
|
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 a = useCategoriesAdmin()
|
||||||
const b = useCategoriesAdmin()
|
const b = useCategoriesAdmin()
|
||||||
|
|
||||||
// Les fonctions sont reinstanciees a chaque appel mais les refs
|
// Les fonctions sont reinstanciees a chaque appel mais les refs
|
||||||
// doivent etre rigoureusement les memes (state au niveau module).
|
// doivent etre rigoureusement les memes (state au niveau module).
|
||||||
expect(a.categories).toBe(b.categories)
|
|
||||||
expect(a.types).toBe(b.types)
|
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', () => {
|
it('une mutation via une instance est visible depuis une autre instance', () => {
|
||||||
const a = useCategoriesAdmin()
|
const a = useCategoriesAdmin()
|
||||||
const b = 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])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,96 +1,56 @@
|
|||||||
/**
|
/**
|
||||||
* Composable d'administration des categories (M0 — Gestion des categories).
|
* Composable de chargement du referentiel CategoryType (M0 — Gestion des
|
||||||
|
* categories).
|
||||||
*
|
*
|
||||||
* Centralise le chargement et le state des deux ressources lues par la page
|
* Apres ERP-73 (composable de liste paginee), la liste des categories
|
||||||
* `/admin/categories` : la liste des categories et le referentiel
|
* elle-meme passe par `usePaginatedList<Category>` directement dans
|
||||||
* CategoryType (utilise dans le select du drawer).
|
* `admin/categories.vue` — c'est un etat propre a la page (pagination,
|
||||||
|
* filtres, tri locaux). Ce composable se concentre donc sur le
|
||||||
|
* referentiel CategoryType : petite collection lue une fois et reutilisee
|
||||||
|
* dans le drawer (select de type) → singleton volontaire pour eviter de
|
||||||
|
* la recharger a chaque ouverture du drawer.
|
||||||
*
|
*
|
||||||
* State singleton au niveau module (meme convention que `useSidebar` /
|
* State singleton au niveau module : reset automatique au logout via
|
||||||
* `useModules` / `useAuditLog`) : reset automatique au logout via
|
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md), et reset
|
||||||
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md : « composables
|
* explicite via `resetCategoriesAdmin()` appele depuis logout.vue.
|
||||||
* avec state singleton doivent etre reinitialises au logout »), et reset
|
|
||||||
* explicite expose via `resetCategoriesAdmin()` appele depuis
|
|
||||||
* `modules/core/pages/logout.vue`.
|
|
||||||
*/
|
*/
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
import type { CategoryType } from '~/modules/catalog/types/category'
|
||||||
import type { HydraCollection } from '~/shared/utils/api'
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dette M0 : pas de pagination serveur sur les ressources Catalog (volumetrie
|
* CategoryType est un referentiel lecture-seule (RG-1.06) avec une
|
||||||
* cible ≤ 300). On force une page geante via `itemsPerPage` pour recuperer
|
* cardinalite minuscule (≤ 5 entrees connues). On force `pagination=false`
|
||||||
* toute la liste en un coup. A basculer en pagination serveur quand la
|
* pour recuperer toutes les entrees en un appel et alimenter le select du
|
||||||
* volumetrie reelle depassera ce plafond — meme pattern que sites.vue.
|
* drawer sans pagination — echappatoire prevue par
|
||||||
|
* `pagination_client_enabled: true` cote API Platform.
|
||||||
*/
|
*/
|
||||||
const HYDRA_NO_PAGINATION = 999
|
const NO_PAGINATION_QUERY = { pagination: 'false' } as const
|
||||||
|
|
||||||
// State singleton — partage entre tous les composants qui appellent le
|
|
||||||
// composable dans la meme session. Les refs sont declarees au niveau module
|
|
||||||
// (pas dans la fonction `useCategoriesAdmin()`) pour eviter qu'une nouvelle
|
|
||||||
// instance soit creee a chaque appel.
|
|
||||||
const categories = ref<Category[]>([])
|
|
||||||
const types = ref<CategoryType[]>([])
|
const types = ref<CategoryType[]>([])
|
||||||
const loading = ref(false)
|
|
||||||
const loadingTypes = ref(false)
|
const loadingTypes = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
function resetCategoriesAdminState(): void {
|
function resetCategoriesAdminState(): void {
|
||||||
categories.value = []
|
|
||||||
types.value = []
|
types.value = []
|
||||||
loading.value = false
|
|
||||||
loadingTypes.value = false
|
loadingTypes.value = false
|
||||||
error.value = null
|
error.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-enregistrement singleton : purge le state sur 401/clearSession pour
|
// Auto-enregistrement singleton : purge le state sur 401/clearSession
|
||||||
// eviter qu'un user suivant (connecte sur le meme onglet) voie l'etat de
|
// pour eviter qu'un user suivant (connecte sur le meme onglet) voie le
|
||||||
// l'ancien. Le logout volontaire (page logout.vue) appelle directement
|
// referentiel de l'ancien tenant. Le logout volontaire (page logout.vue)
|
||||||
// `resetCategoriesAdmin()` ci-dessous.
|
// appelle directement `resetCategoriesAdmin()` ci-dessous.
|
||||||
onAuthSessionCleared(resetCategoriesAdminState)
|
onAuthSessionCleared(resetCategoriesAdminState)
|
||||||
|
|
||||||
export function useCategoriesAdmin() {
|
export function useCategoriesAdmin() {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Charge la liste des categories. Le serveur exclut les soft-deleted par
|
* Charge le referentiel CategoryType. Appele a l'ouverture de la page
|
||||||
* defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination
|
* admin pour que le select du drawer ait deja les options pretes au
|
||||||
* serveur (volumetrie ≤ 300, pagination front via MalioDataTable).
|
* moment de la creation/edition.
|
||||||
*
|
|
||||||
* `includeDeleted=true` permet a un user avec `catalog.categories.manage`
|
|
||||||
* de voir les soft-deleted (RG-1.09) — au M0 la page n'utilise pas cette
|
|
||||||
* option mais on l'expose pour la suite (corbeille future).
|
|
||||||
*
|
|
||||||
* Swallow volontaire : un 403 (user sans permission view) ne doit pas
|
|
||||||
* toaster — la sidebar masque deja l'entree pour ces users, on tombe sur
|
|
||||||
* la page seulement par URL directe et on affiche un tableau vide propre.
|
|
||||||
*/
|
|
||||||
async function fetchAll(includeDeleted = false): Promise<void> {
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
try {
|
|
||||||
const query: Record<string, unknown> = { itemsPerPage: HYDRA_NO_PAGINATION }
|
|
||||||
if (includeDeleted) {
|
|
||||||
query.includeDeleted = 'true'
|
|
||||||
}
|
|
||||||
const data = await api.get<HydraCollection<Category>>(
|
|
||||||
'/categories',
|
|
||||||
query,
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
categories.value = data.member ?? []
|
|
||||||
} catch (e) {
|
|
||||||
categories.value = []
|
|
||||||
error.value = (e as Error)?.message ?? 'Erreur de chargement'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Charge le referentiel CategoryType (lecture seule, RG-1.06). Appele a
|
|
||||||
* l'ouverture de la page admin pour que le select du drawer ait deja les
|
|
||||||
* options pretes au moment de la creation/edition.
|
|
||||||
*
|
*
|
||||||
* Toast desactive : on stocke l'erreur dans `error` plutot que de
|
* Toast desactive : on stocke l'erreur dans `error` plutot que de
|
||||||
* spammer un toast — le drawer affichera l'erreur inline s'il y a lieu.
|
* spammer un toast — le drawer affichera l'erreur inline s'il y a lieu.
|
||||||
@@ -100,7 +60,7 @@ export function useCategoriesAdmin() {
|
|||||||
try {
|
try {
|
||||||
const data = await api.get<HydraCollection<CategoryType>>(
|
const data = await api.get<HydraCollection<CategoryType>>(
|
||||||
'/category_types',
|
'/category_types',
|
||||||
{ itemsPerPage: HYDRA_NO_PAGINATION },
|
NO_PAGINATION_QUERY,
|
||||||
{ toast: false },
|
{ toast: false },
|
||||||
)
|
)
|
||||||
types.value = data.member ?? []
|
types.value = data.member ?? []
|
||||||
@@ -113,21 +73,18 @@ export function useCategoriesAdmin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()` pour
|
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()`
|
||||||
* garantir que la prochaine session reparte sur un state propre meme si
|
* pour garantir que la prochaine session reparte sur un state propre
|
||||||
* `clearSession()` n'a pas ete declenche (cas logout volontaire).
|
* meme si `clearSession()` n'a pas ete declenche (cas logout volontaire).
|
||||||
*/
|
*/
|
||||||
function resetCategoriesAdmin(): void {
|
function resetCategoriesAdmin(): void {
|
||||||
resetCategoriesAdminState()
|
resetCategoriesAdminState()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
categories,
|
|
||||||
types,
|
types,
|
||||||
loading,
|
|
||||||
loadingTypes,
|
loadingTypes,
|
||||||
error,
|
error,
|
||||||
fetchAll,
|
|
||||||
fetchTypes,
|
fetchTypes,
|
||||||
resetCategoriesAdmin,
|
resetCategoriesAdmin,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,18 +13,23 @@
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Table des categories. Affichage exhaustif (volumetrie cible
|
<!-- Table des categories. Tri serveur (name ASC, RG-1.10) +
|
||||||
<= 300, cf. spec § 4.1) — tri 100% serveur via CategoryProvider
|
pagination serveur via usePaginatedList (#73). Le composable
|
||||||
(name ASC, RG-1.10). La barre de pagination du MalioDataTable
|
remplace l'ancien chargement « tout en un coup » a volumetrie
|
||||||
reste cosmetique tant qu'aucun slice client n'est cable : a
|
cible ≤ 300 — la pagination est desormais alignee sur la regle
|
||||||
traiter cote @malio/layer-ui le jour ou la volumetrie monte. -->
|
projet (toute collection paginee, regle ABSOLUE n°13). -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="categoryItems"
|
:items="categoryItems"
|
||||||
:total-items="categories.length"
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
:row-clickable="true"
|
:row-clickable="true"
|
||||||
:empty-message="t('admin.categories.noCategories')"
|
:empty-message="t('admin.categories.noCategories')"
|
||||||
@row-click="onRowClick"
|
@row-click="onRowClick"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Drawer creation / consultation / edition. -->
|
<!-- Drawer creation / consultation / edition. -->
|
||||||
@@ -50,13 +55,27 @@ import type { Category } from '~/modules/catalog/types/category'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
const { categories, fetchAll, fetchTypes } = useCategoriesAdmin()
|
const { fetchTypes } = useCategoriesAdmin()
|
||||||
const { submitDelete } = useCategoryForm()
|
const { submitDelete } = useCategoryForm()
|
||||||
|
|
||||||
useHead({ title: t('admin.categories.title') })
|
useHead({ title: t('admin.categories.title') })
|
||||||
|
|
||||||
const canManage = computed(() => can('catalog.categories.manage'))
|
const canManage = computed(() => can('catalog.categories.manage'))
|
||||||
|
|
||||||
|
// Pagination serveur via le composable partage (#73). Le CategoryProvider
|
||||||
|
// applique deja name ASC (RG-1.10) — pas besoin de defaultSort cote front
|
||||||
|
// tant qu'aucun OrderFilter n'est expose.
|
||||||
|
const {
|
||||||
|
items: categories,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: fetchCategories,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
} = usePaginatedList<Category>({ url: '/categories' })
|
||||||
|
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const selectedCategory = ref<Category | null>(null)
|
const selectedCategory = ref<Category | null>(null)
|
||||||
const deleteModalOpen = ref(false)
|
const deleteModalOpen = ref(false)
|
||||||
@@ -118,7 +137,7 @@ async function handleDelete(): Promise<void> {
|
|||||||
deleteModalOpen.value = false
|
deleteModalOpen.value = false
|
||||||
categoryToDelete.value = null
|
categoryToDelete.value = null
|
||||||
drawerOpen.value = false
|
drawerOpen.value = false
|
||||||
await fetchAll()
|
await fetchCategories()
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = false
|
deleting.value = false
|
||||||
@@ -126,14 +145,14 @@ async function handleDelete(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onCategorySaved() {
|
function onCategorySaved() {
|
||||||
fetchAll()
|
fetchCategories()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chargement initial des deux ressources (liste + referentiel des types).
|
// Chargement initial des deux ressources (liste + referentiel des types).
|
||||||
// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le
|
// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le
|
||||||
// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ».
|
// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ».
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchAll()
|
fetchCategories()
|
||||||
fetchTypes()
|
fetchTypes()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -13,14 +13,19 @@
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Table des roles -->
|
<!-- Table des roles — pagination serveur via usePaginatedList (#73). -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="roleItems"
|
:items="roleItems"
|
||||||
:total-items="roles.length"
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
:row-clickable="canManage"
|
:row-clickable="canManage"
|
||||||
:empty-message="t('admin.roles.noRoles')"
|
:empty-message="t('admin.roles.noRoles')"
|
||||||
@row-click="onRowClick"
|
@row-click="onRowClick"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
>
|
>
|
||||||
<template #cell-code="{ item }">
|
<template #cell-code="{ item }">
|
||||||
<span class="font-mono text-xs">{{ item.code }}</span>
|
<span class="font-mono text-xs">{{ item.code }}</span>
|
||||||
@@ -66,8 +71,17 @@ const canManage = computed(() => can('core.roles.manage'))
|
|||||||
|
|
||||||
useHead({ title: t('admin.roles.title') })
|
useHead({ title: t('admin.roles.title') })
|
||||||
|
|
||||||
const roles = ref<Role[]>([])
|
// Pagination serveur via le composable partage (#73).
|
||||||
const loading = ref(false)
|
const {
|
||||||
|
items: roles,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadRoles,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
} = usePaginatedList<Role>({ url: '/roles' })
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'label', label: t('admin.roles.table.label') },
|
{ key: 'label', label: t('admin.roles.table.label') },
|
||||||
@@ -102,25 +116,6 @@ const deleteModalOpen = ref(false)
|
|||||||
const roleToDelete = ref<Role | null>(null)
|
const roleToDelete = ref<Role | null>(null)
|
||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
|
|
||||||
// Charger la liste des roles
|
|
||||||
async function loadRoles() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await api.get<{ member: Role[] }>(
|
|
||||||
'/roles',
|
|
||||||
{},
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
roles.value = data.member
|
|
||||||
} catch {
|
|
||||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
|
||||||
// requete reussie avant une perte reseau ou 403).
|
|
||||||
roles.value = []
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateDrawer() {
|
function openCreateDrawer() {
|
||||||
selectedRole.value = null
|
selectedRole.value = null
|
||||||
drawerOpen.value = true
|
drawerOpen.value = true
|
||||||
|
|||||||
@@ -2,14 +2,19 @@
|
|||||||
<div>
|
<div>
|
||||||
<PageHeader>{{ t('admin.users.title') }}</PageHeader>
|
<PageHeader>{{ t('admin.users.title') }}</PageHeader>
|
||||||
|
|
||||||
<!-- Table des utilisateurs -->
|
<!-- Table des utilisateurs — pagination serveur via usePaginatedList (#73). -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="userItems"
|
:items="userItems"
|
||||||
:total-items="users.length"
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
:row-clickable="canManage"
|
:row-clickable="canManage"
|
||||||
:empty-message="t('admin.users.noUsers')"
|
:empty-message="t('admin.users.noUsers')"
|
||||||
@row-click="onRowClick"
|
@row-click="onRowClick"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
>
|
>
|
||||||
<template #cell-admin="{ item }">
|
<template #cell-admin="{ item }">
|
||||||
<span
|
<span
|
||||||
@@ -34,15 +39,26 @@
|
|||||||
import type { UserListItem } from '~/shared/types/rbac'
|
import type { UserListItem } from '~/shared/types/rbac'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
|
|
||||||
useHead({ title: t('admin.users.title') })
|
useHead({ title: t('admin.users.title') })
|
||||||
|
|
||||||
const canManage = computed(() => can('core.users.manage'))
|
const canManage = computed(() => can('core.users.manage'))
|
||||||
|
|
||||||
const users = ref<UserListItem[]>([])
|
// Pagination serveur via le composable partage (#73). Le payload `users`
|
||||||
const loading = ref(false)
|
// reste leger (pas de detail RBAC dans la liste — cf. commentaire colonne
|
||||||
|
// "Sites" plus bas) ce qui rend la pagination 10/25/50 par page confortable.
|
||||||
|
const {
|
||||||
|
items: users,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadUsers,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
} = usePaginatedList<UserListItem>({ url: '/users' })
|
||||||
|
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const selectedUser = ref<UserListItem | null>(null)
|
const selectedUser = ref<UserListItem | null>(null)
|
||||||
|
|
||||||
@@ -67,21 +83,6 @@ const userItems = computed(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
async function loadUsers() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const usersData = await api.get<{ member: UserListItem[] }>('/users', {}, { toast: false })
|
|
||||||
users.value = usersData.member
|
|
||||||
} catch {
|
|
||||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
|
||||||
// requete reussie avant une perte reseau ou 403). Pas de toast par
|
|
||||||
// design ici : on laisse la liste vide parler d'elle-meme.
|
|
||||||
users.value = []
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserById(id: number): UserListItem | undefined {
|
function getUserById(id: number): UserListItem | undefined {
|
||||||
return users.value.find(u => u.id === id)
|
return users.value.find(u => u.id === id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,14 +13,19 @@
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Table des sites -->
|
<!-- Table des sites — pagination serveur via usePaginatedList (#73). -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="siteItems"
|
:items="siteItems"
|
||||||
:total-items="sites.length"
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
:row-clickable="canManage"
|
:row-clickable="canManage"
|
||||||
:empty-message="t('admin.sites.noSites')"
|
:empty-message="t('admin.sites.noSites')"
|
||||||
@row-click="onRowClick"
|
@row-click="onRowClick"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
>
|
>
|
||||||
<template #cell-color="{ item }">
|
<template #cell-color="{ item }">
|
||||||
<span class="inline-flex items-center gap-2">
|
<span class="inline-flex items-center gap-2">
|
||||||
@@ -67,8 +72,20 @@ const canManage = computed(() => can('sites.manage'))
|
|||||||
|
|
||||||
useHead({ title: t('admin.sites.title') })
|
useHead({ title: t('admin.sites.title') })
|
||||||
|
|
||||||
const sites = ref<Site[]>([])
|
// Pagination serveur via le composable partage (#73). Aucun OrderFilter
|
||||||
const loading = ref(false)
|
// declare cote API Platform sur Site, donc on s'appuie sur le tri par
|
||||||
|
// defaut du repository (id ASC). Le composable est neanmoins pret a
|
||||||
|
// recevoir un `defaultSort` ou des filtres le jour ou l'API les expose.
|
||||||
|
const {
|
||||||
|
items: sites,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadSites,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
} = usePaginatedList<Site>({ url: '/sites' })
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'name', label: t('admin.sites.table.name') },
|
{ key: 'name', label: t('admin.sites.table.name') },
|
||||||
@@ -107,24 +124,6 @@ const deleteModalOpen = ref(false)
|
|||||||
const siteToDelete = ref<Site | null>(null)
|
const siteToDelete = ref<Site | null>(null)
|
||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
|
|
||||||
async function loadSites() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await api.get<{ member: Site[] }>(
|
|
||||||
'/sites',
|
|
||||||
{ itemsPerPage: 999 },
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
sites.value = data.member
|
|
||||||
} catch {
|
|
||||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
|
||||||
// requete reussie avant une perte reseau ou 403).
|
|
||||||
sites.value = []
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateDrawer() {
|
function openCreateDrawer() {
|
||||||
selectedSite.value = null
|
selectedSite.value = null
|
||||||
drawerOpen.value = true
|
drawerOpen.value = true
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { usePaginatedList } from '../usePaginatedList'
|
||||||
|
|
||||||
|
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du composable `usePaginatedList`.
|
||||||
|
*
|
||||||
|
* Couvre les invariants critiques :
|
||||||
|
* - parse Hydra (member / totalItems)
|
||||||
|
* - navigation page (goToPage / next / prev / bornes)
|
||||||
|
* - changement items/page → retour page 1
|
||||||
|
* - mutation filtres / tri → retour page 1
|
||||||
|
* - cas limite : page courante hors borne apres filtre → derniere page valide
|
||||||
|
* - liste vide / page unique
|
||||||
|
* - reset → defaults
|
||||||
|
* - swallow d'erreur reseau (la promesse `fetch` ne reject jamais)
|
||||||
|
* - header `Accept: application/ld+json` toujours envoye (besoin du
|
||||||
|
* paginator Hydra cote API Platform 4).
|
||||||
|
*/
|
||||||
|
describe('usePaginatedList', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockApiGet.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
function mockResponse(member: unknown[], totalItems: number): void {
|
||||||
|
mockApiGet.mockResolvedValueOnce({ member, totalItems })
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fetch initial : page=1, itemsPerPage par defaut, parse Hydra', async () => {
|
||||||
|
mockResponse([{ id: 1 }, { id: 2 }], 42)
|
||||||
|
const list = usePaginatedList<{ id: number }>({ url: '/sites' })
|
||||||
|
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||||
|
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||||
|
expect(url).toBe('/sites')
|
||||||
|
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||||
|
expect(opts).toMatchObject({
|
||||||
|
toast: false,
|
||||||
|
headers: { Accept: 'application/ld+json' },
|
||||||
|
})
|
||||||
|
expect(list.items.value).toEqual([{ id: 1 }, { id: 2 }])
|
||||||
|
expect(list.totalItems.value).toBe(42)
|
||||||
|
expect(list.totalPages.value).toBe(5)
|
||||||
|
expect(list.isEmpty.value).toBe(false)
|
||||||
|
expect(list.isSinglePage.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('itemsPerPage personnalise est respecte au premier fetch', async () => {
|
||||||
|
mockResponse([], 0)
|
||||||
|
const list = usePaginatedList({ url: '/users', defaultItemsPerPage: 25 })
|
||||||
|
await list.fetch()
|
||||||
|
expect(mockApiGet.mock.calls[0][1]).toMatchObject({ itemsPerPage: 25 })
|
||||||
|
expect(list.itemsPerPage.value).toBe(25)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('goToPage(N) declenche un nouvel appel avec page=N', async () => {
|
||||||
|
mockResponse([{ id: 1 }], 30) // page 1
|
||||||
|
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockResponse([{ id: 2 }], 30) // page 2
|
||||||
|
await list.goToPage(2)
|
||||||
|
|
||||||
|
expect(mockApiGet).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2, itemsPerPage: 10 })
|
||||||
|
expect(list.currentPage.value).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('goToPage hors borne sup. est clampe a totalPages', async () => {
|
||||||
|
mockResponse([], 30) // totalPages = 3
|
||||||
|
const list = usePaginatedList({ url: '/roles' })
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockResponse([], 30)
|
||||||
|
await list.goToPage(999)
|
||||||
|
|
||||||
|
expect(list.currentPage.value).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('goToPage hors borne inf. est clampe a 1 (no-op si deja en 1)', async () => {
|
||||||
|
mockResponse([], 30)
|
||||||
|
const list = usePaginatedList({ url: '/roles' })
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockApiGet.mockClear()
|
||||||
|
await list.goToPage(-5)
|
||||||
|
|
||||||
|
// Deja en page 1 -> aucun nouvel appel.
|
||||||
|
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
||||||
|
expect(list.currentPage.value).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nextPage / prevPage avancent et reculent dans les bornes', async () => {
|
||||||
|
mockResponse([], 30) // page 1, totalPages 3
|
||||||
|
const list = usePaginatedList({ url: '/roles' })
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockResponse([], 30)
|
||||||
|
await list.nextPage()
|
||||||
|
expect(list.currentPage.value).toBe(2)
|
||||||
|
|
||||||
|
mockResponse([], 30)
|
||||||
|
await list.nextPage()
|
||||||
|
expect(list.currentPage.value).toBe(3)
|
||||||
|
|
||||||
|
// En derniere page -> no-op
|
||||||
|
mockApiGet.mockClear()
|
||||||
|
await list.nextPage()
|
||||||
|
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
||||||
|
expect(list.currentPage.value).toBe(3)
|
||||||
|
|
||||||
|
mockResponse([], 30)
|
||||||
|
await list.prevPage()
|
||||||
|
expect(list.currentPage.value).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setItemsPerPage revient en page 1 et refetch', async () => {
|
||||||
|
mockResponse([], 100)
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
await list.fetch()
|
||||||
|
// place-toi page 3
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.goToPage(3)
|
||||||
|
expect(list.currentPage.value).toBe(3)
|
||||||
|
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setItemsPerPage(25)
|
||||||
|
|
||||||
|
expect(list.currentPage.value).toBe(1)
|
||||||
|
expect(list.itemsPerPage.value).toBe(25)
|
||||||
|
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1, itemsPerPage: 25 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setItemsPerPage no-op si meme valeur', async () => {
|
||||||
|
mockResponse([], 10)
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockApiGet.mockClear()
|
||||||
|
await list.setItemsPerPage(10)
|
||||||
|
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setFilters fusionne et retombe en page 1', async () => {
|
||||||
|
mockResponse([], 100)
|
||||||
|
const list = usePaginatedList<unknown, { name?: string; active?: boolean }>({
|
||||||
|
url: '/users',
|
||||||
|
defaultFilters: { active: true },
|
||||||
|
})
|
||||||
|
await list.fetch()
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.goToPage(2)
|
||||||
|
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setFilters({ name: 'alice' })
|
||||||
|
|
||||||
|
expect(list.currentPage.value).toBe(1)
|
||||||
|
expect(list.filters.value).toEqual({ active: true, name: 'alice' })
|
||||||
|
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({
|
||||||
|
page: 1,
|
||||||
|
active: true,
|
||||||
|
name: 'alice',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setFilters({ key: undefined }) supprime la cle', async () => {
|
||||||
|
mockResponse([], 100)
|
||||||
|
const list = usePaginatedList<unknown, { name?: string }>({
|
||||||
|
url: '/users',
|
||||||
|
defaultFilters: { name: 'alice' },
|
||||||
|
})
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setFilters({ name: undefined })
|
||||||
|
|
||||||
|
expect(list.filters.value).toEqual({})
|
||||||
|
// Le query envoye ne contient plus `name` (compactQuery elimine
|
||||||
|
// aussi les valeurs vides).
|
||||||
|
const q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||||
|
expect(q.name).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setFilters({ replace: true }) remplace integralement', async () => {
|
||||||
|
mockResponse([], 100)
|
||||||
|
const list = usePaginatedList<unknown, { a?: string; b?: string }>({
|
||||||
|
url: '/users',
|
||||||
|
defaultFilters: { a: 'x' },
|
||||||
|
})
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setFilters({ b: 'y' }, { replace: true })
|
||||||
|
|
||||||
|
expect(list.filters.value).toEqual({ b: 'y' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setSort envoie order[field]=direction et reset page', async () => {
|
||||||
|
mockResponse([], 100)
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
await list.fetch()
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.goToPage(2)
|
||||||
|
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setSort({ field: 'username', direction: 'desc' })
|
||||||
|
|
||||||
|
expect(list.currentPage.value).toBe(1)
|
||||||
|
const q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||||
|
expect(q['order[username]']).toBe('desc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setSort(null) retire le tri', async () => {
|
||||||
|
mockResponse([], 100)
|
||||||
|
const list = usePaginatedList({
|
||||||
|
url: '/users',
|
||||||
|
defaultSort: { field: 'name', direction: 'asc' },
|
||||||
|
})
|
||||||
|
await list.fetch()
|
||||||
|
// Le tri initial est applique
|
||||||
|
let q = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||||
|
expect(q['order[name]']).toBe('asc')
|
||||||
|
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setSort(null)
|
||||||
|
q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||||
|
expect(q['order[name]']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('page hors borne apres filtre retombe sur la derniere page valide', async () => {
|
||||||
|
// 1er fetch : page 1 sur une grosse liste
|
||||||
|
mockResponse([], 50) // 5 pages
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
await list.fetch()
|
||||||
|
mockResponse([], 50)
|
||||||
|
await list.goToPage(5)
|
||||||
|
expect(list.currentPage.value).toBe(5)
|
||||||
|
|
||||||
|
// Application d'un filtre : la nouvelle reponse a 12 items
|
||||||
|
// (donc 2 pages) mais on demande page=5 → l'API renvoie member=[]
|
||||||
|
// et le composable doit refetcher sur page=2.
|
||||||
|
mockApiGet.mockReset()
|
||||||
|
mockApiGet
|
||||||
|
// 1er appel : page=5 alors qu'il ne reste que 2 pages
|
||||||
|
.mockResolvedValueOnce({ member: [], totalItems: 12 })
|
||||||
|
// 2eme appel : refetch automatique sur page=2
|
||||||
|
.mockResolvedValueOnce({ member: [{ id: 1 }, { id: 2 }], totalItems: 12 })
|
||||||
|
|
||||||
|
await list.setFilters({ active: true } as never)
|
||||||
|
|
||||||
|
// setFilters reset page a 1 → c'est le cas standard, pas le hors borne.
|
||||||
|
// Pour declencher le cas hors borne, on doit forcer la page > totalPages.
|
||||||
|
expect(list.currentPage.value).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('declenche le retry sur derniere page si currentPage > totalPages apres fetch', async () => {
|
||||||
|
// Scenario : on a fait un fetch (5 pages, page=1). Sans toucher aux
|
||||||
|
// filtres mais entre deux fetchs la donnee a change cote serveur,
|
||||||
|
// la page courante peut devenir hors borne. On force le scenario
|
||||||
|
// en montant manuellement currentPage via goToPage borne, puis en
|
||||||
|
// simulant une reponse plus petite.
|
||||||
|
mockResponse([], 50) // 5 pages
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockResponse([], 50)
|
||||||
|
await list.goToPage(5)
|
||||||
|
expect(list.currentPage.value).toBe(5)
|
||||||
|
|
||||||
|
// Maintenant simule : refetch -> totalItems chute a 12 (2 pages),
|
||||||
|
// le composable doit refetcher sur page=2.
|
||||||
|
mockApiGet.mockReset()
|
||||||
|
mockApiGet
|
||||||
|
.mockResolvedValueOnce({ member: [], totalItems: 12 }) // page=5 vide
|
||||||
|
.mockResolvedValueOnce({ member: [{ id: 11 }, { id: 12 }], totalItems: 12 }) // page=2
|
||||||
|
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
expect(mockApiGet).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2 })
|
||||||
|
expect(list.currentPage.value).toBe(2)
|
||||||
|
expect(list.items.value).toEqual([{ id: 11 }, { id: 12 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liste vide : isEmpty true, isSinglePage true', async () => {
|
||||||
|
mockResponse([], 0)
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
expect(list.totalItems.value).toBe(0)
|
||||||
|
expect(list.isEmpty.value).toBe(true)
|
||||||
|
expect(list.isSinglePage.value).toBe(true)
|
||||||
|
expect(list.totalPages.value).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isEmpty est faux avant le premier fetch (etat indetermine)', () => {
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
expect(list.isEmpty.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reset revient aux defaults', async () => {
|
||||||
|
mockResponse([], 100)
|
||||||
|
const list = usePaginatedList<unknown, { a?: string }>({
|
||||||
|
url: '/users',
|
||||||
|
defaultItemsPerPage: 10,
|
||||||
|
defaultFilters: { a: 'x' },
|
||||||
|
defaultSort: { field: 'name', direction: 'asc' },
|
||||||
|
})
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setItemsPerPage(50)
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setFilters({ a: 'y' })
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setSort({ field: 'id', direction: 'desc' })
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.goToPage(2)
|
||||||
|
expect(list.currentPage.value).toBe(2)
|
||||||
|
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.reset()
|
||||||
|
|
||||||
|
expect(list.itemsPerPage.value).toBe(10)
|
||||||
|
expect(list.filters.value).toEqual({ a: 'x' })
|
||||||
|
expect(list.sort.value).toEqual({ field: 'name', direction: 'asc' })
|
||||||
|
expect(list.currentPage.value).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('swallow l\'erreur reseau : items vides, loading false, fetch ne reject pas', async () => {
|
||||||
|
mockApiGet.mockRejectedValueOnce(new Error('boom'))
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
|
||||||
|
await expect(list.fetch()).resolves.toBeUndefined()
|
||||||
|
expect(list.items.value).toEqual([])
|
||||||
|
expect(list.totalItems.value).toBe(0)
|
||||||
|
expect(list.loading.value).toBe(false)
|
||||||
|
// L'erreur est consideree comme un fetch consume -> isEmpty=true.
|
||||||
|
expect(list.isEmpty.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extraQuery est injecte a chaque fetch (ex : includeDeleted)', async () => {
|
||||||
|
mockResponse([], 0)
|
||||||
|
const list = usePaginatedList({
|
||||||
|
url: '/categories',
|
||||||
|
extraQuery: { includeDeleted: 'true' },
|
||||||
|
})
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
expect(mockApiGet.mock.calls[0][1]).toMatchObject({ includeDeleted: 'true' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('valeurs nulles/vides des filtres ne sont pas envoyees', async () => {
|
||||||
|
mockResponse([], 0)
|
||||||
|
const list = usePaginatedList<unknown, { name?: string; q?: string }>({
|
||||||
|
url: '/users',
|
||||||
|
defaultFilters: { name: '', q: undefined } as never,
|
||||||
|
})
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
const q = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||||
|
expect(q.name).toBeUndefined()
|
||||||
|
expect(q.q).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refresh() est un alias de fetch()', async () => {
|
||||||
|
mockResponse([{ id: 1 }], 1)
|
||||||
|
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
||||||
|
|
||||||
|
await list.refresh()
|
||||||
|
expect(list.items.value).toEqual([{ id: 1 }])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import { computed, ref, type Ref } from 'vue'
|
||||||
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable generique de liste paginee serveur.
|
||||||
|
*
|
||||||
|
* Responsabilites :
|
||||||
|
* - centraliser l'etat tableau (page courante, items/page, tri, filtres,
|
||||||
|
* totalItems, items, loading, error) cote local — JAMAIS dans l'URL,
|
||||||
|
* conformement a la regle ABSOLUE n°6 du CLAUDE.md (« Jamais persister
|
||||||
|
* l'etat de tableau dans l'URL »).
|
||||||
|
* - dialoguer avec une ressource API Platform 4 (Hydra) en passant
|
||||||
|
* `page`, `itemsPerPage` et le tri/filtres en query params.
|
||||||
|
* - exposer une API simple a brancher sur `MalioDataTable`
|
||||||
|
* (props page/perPage/totalItems + events update:page / update:per-page).
|
||||||
|
*
|
||||||
|
* Volontairement **par-instance** (state local a chaque appel) : a la
|
||||||
|
* difference de `useAuditLog` / `useCategoriesAdmin` qui sont des
|
||||||
|
* singletons module-level partages, une liste paginee est propre a son
|
||||||
|
* ecran et ne doit pas etre partagee entre pages (sinon un retour
|
||||||
|
* arriere reprendrait la pagination d'une autre liste).
|
||||||
|
*
|
||||||
|
* Pas de gestion URL : si une page veut un deep link (ex : ouvrir un
|
||||||
|
* detail), elle le fait via sa propre route, pas via la query string
|
||||||
|
* de pagination. Derogation possible uniquement si l'utilisateur le
|
||||||
|
* demande explicitement, cf. CLAUDE.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direction de tri serveur. API Platform 4 attend `asc` ou `desc` via la
|
||||||
|
* syntaxe `?order[field]=asc`.
|
||||||
|
*/
|
||||||
|
export type SortDirection = 'asc' | 'desc'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specification de tri : un seul champ trie a la fois cote front (la
|
||||||
|
* majorite des tableaux Malio n'expose pas le multi-tri). Si null, aucun
|
||||||
|
* `order[...]` n'est envoye et l'API applique son tri par defaut.
|
||||||
|
*/
|
||||||
|
export interface SortSpec {
|
||||||
|
field: string
|
||||||
|
direction: SortDirection
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type des filtres : un dictionnaire de valeurs serialisables en query
|
||||||
|
* params. Le caller decide du mapping (ex : `{ active: true }`,
|
||||||
|
* `{ 'name[ilike]': 'a' }`). Valeurs `null` / `undefined` / chaines vides
|
||||||
|
* sont automatiquement omises au moment de la requete.
|
||||||
|
*/
|
||||||
|
export type PaginatedListFilters = Record<string, string | number | boolean | string[] | null | undefined>
|
||||||
|
|
||||||
|
export interface UsePaginatedListOptions<F extends PaginatedListFilters = PaginatedListFilters> {
|
||||||
|
/** URL relative au prefix `/api` (ex : `/sites`, `/categories`). */
|
||||||
|
url: string
|
||||||
|
/** Items par page initial. Defaut 10 (aligne avec le defaut serveur). */
|
||||||
|
defaultItemsPerPage?: number
|
||||||
|
/** Options proposees dans le selecteur items/page. Defaut [10, 25, 50]. */
|
||||||
|
itemsPerPageOptions?: number[]
|
||||||
|
/** Filtres initiaux. */
|
||||||
|
defaultFilters?: F
|
||||||
|
/** Tri initial. */
|
||||||
|
defaultSort?: SortSpec | null
|
||||||
|
/**
|
||||||
|
* Query params additionnels propres a la ressource (ex : `includeDeleted=true`,
|
||||||
|
* `groups[]=foo`) injectes a chaque requete. Reactifs si une ref / computed
|
||||||
|
* est fournie via `refresh()` apres mutation.
|
||||||
|
*/
|
||||||
|
extraQuery?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePaginatedListReturn<T, F extends PaginatedListFilters = PaginatedListFilters> {
|
||||||
|
/** Items de la page courante. */
|
||||||
|
items: Ref<T[]>
|
||||||
|
/** Total d'items (toutes pages) renvoye par Hydra. */
|
||||||
|
totalItems: Ref<number>
|
||||||
|
/** Page courante (1-based). */
|
||||||
|
currentPage: Ref<number>
|
||||||
|
/** Taille de page courante. */
|
||||||
|
itemsPerPage: Ref<number>
|
||||||
|
/** Options exposees au selecteur items/page. */
|
||||||
|
itemsPerPageOptions: Ref<number[]>
|
||||||
|
/** Nombre total de pages (≥ 1). */
|
||||||
|
totalPages: Ref<number>
|
||||||
|
/** Indicateur de chargement (vrai pendant `fetch()`). */
|
||||||
|
loading: Ref<boolean>
|
||||||
|
/** Vrai apres au moins un fetch reussi avec 0 item. */
|
||||||
|
isEmpty: Ref<boolean>
|
||||||
|
/** Vrai si la collection tient en une seule page (totalPages <= 1). */
|
||||||
|
isSinglePage: Ref<boolean>
|
||||||
|
/** Filtres courants (mutation via `setFilters`). */
|
||||||
|
filters: Ref<F>
|
||||||
|
/** Tri courant (mutation via `setSort`). */
|
||||||
|
sort: Ref<SortSpec | null>
|
||||||
|
/** Lance un fetch contre l'API et met a jour items/totalItems. */
|
||||||
|
fetch: () => Promise<void>
|
||||||
|
/** Va a la page demandee (bornes appliquees : 1 ≤ p ≤ totalPages). */
|
||||||
|
goToPage: (page: number) => Promise<void>
|
||||||
|
/** Page suivante (no-op si deja en derniere page). */
|
||||||
|
nextPage: () => Promise<void>
|
||||||
|
/** Page precedente (no-op si deja en premiere page). */
|
||||||
|
prevPage: () => Promise<void>
|
||||||
|
/** Change la taille de page et revient en page 1. */
|
||||||
|
setItemsPerPage: (value: number) => Promise<void>
|
||||||
|
/** Applique de nouveaux filtres et revient en page 1. */
|
||||||
|
setFilters: (next: Partial<F>, options?: { replace?: boolean }) => Promise<void>
|
||||||
|
/** Change le tri et revient en page 1. */
|
||||||
|
setSort: (next: SortSpec | null) => Promise<void>
|
||||||
|
/** Reinitialise filtres + tri + page sur les valeurs par defaut. */
|
||||||
|
reset: () => Promise<void>
|
||||||
|
/** Alias de `fetch()` (intention plus claire dans certains contextes). */
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force `application/ld+json` : sous `application/json`, API Platform 4
|
||||||
|
* renvoie un tableau plat sans envelope de pagination — on ne pourrait pas
|
||||||
|
* lire `totalItems` ni `view`. Voir aussi `useAuditLog.ts`.
|
||||||
|
*/
|
||||||
|
const JSONLD_HEADERS = { Accept: 'application/ld+json' } as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtre les entrees nulles/undefined/vides d'un objet de query : evite
|
||||||
|
* d'envoyer `?foo=&bar=null` a l'API qui declencherait parfois des erreurs
|
||||||
|
* de filtre cote Symfony (`FilterInterface::apply` strict).
|
||||||
|
*/
|
||||||
|
function compactQuery(raw: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {}
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (value === null || value === undefined) continue
|
||||||
|
if (typeof value === 'string' && value === '') continue
|
||||||
|
if (Array.isArray(value) && value.length === 0) continue
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePaginatedList<T, F extends PaginatedListFilters = PaginatedListFilters>(
|
||||||
|
options: UsePaginatedListOptions<F>,
|
||||||
|
): UsePaginatedListReturn<T, F> {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const defaultItemsPerPage = options.defaultItemsPerPage ?? 10
|
||||||
|
const initialFilters = { ...(options.defaultFilters ?? ({} as F)) } as F
|
||||||
|
const initialSort = options.defaultSort ?? null
|
||||||
|
|
||||||
|
const items = ref<T[]>([]) as Ref<T[]>
|
||||||
|
const totalItems = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const itemsPerPage = ref(defaultItemsPerPage)
|
||||||
|
const itemsPerPageOptions = ref(options.itemsPerPageOptions ?? [10, 25, 50])
|
||||||
|
const loading = ref(false)
|
||||||
|
// `hasFetched` evite que `isEmpty` retourne `true` avant le premier
|
||||||
|
// chargement (etat initial = 0 items mais on ne sait pas encore si la
|
||||||
|
// ressource est vide ou en cours de chargement). Un appel reseau au
|
||||||
|
// moins doit avoir abouti pour qu'on annonce une liste « vide ».
|
||||||
|
const hasFetched = ref(false)
|
||||||
|
const filters = ref({ ...initialFilters }) as Ref<F>
|
||||||
|
const sort = ref<SortSpec | null>(initialSort ? { ...initialSort } : null)
|
||||||
|
|
||||||
|
const totalPages = computed(() => {
|
||||||
|
if (totalItems.value <= 0 || itemsPerPage.value <= 0) return 1
|
||||||
|
return Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value))
|
||||||
|
})
|
||||||
|
const isEmpty = computed(() => hasFetched.value && totalItems.value === 0)
|
||||||
|
const isSinglePage = computed(() => totalPages.value <= 1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit l'objet query envoye a l'API : pagination + tri + filtres +
|
||||||
|
* extras propres a la ressource. Les filtres `null`/`undefined`/'' sont
|
||||||
|
* elimines pour ne pas polluer l'URL.
|
||||||
|
*/
|
||||||
|
function buildQuery(): Record<string, unknown> {
|
||||||
|
const query: Record<string, unknown> = {
|
||||||
|
page: currentPage.value,
|
||||||
|
itemsPerPage: itemsPerPage.value,
|
||||||
|
}
|
||||||
|
if (sort.value) {
|
||||||
|
// Format API Platform : ?order[field]=asc
|
||||||
|
query[`order[${sort.value.field}]`] = sort.value.direction
|
||||||
|
}
|
||||||
|
if (options.extraQuery) {
|
||||||
|
Object.assign(query, options.extraQuery)
|
||||||
|
}
|
||||||
|
Object.assign(query, filters.value)
|
||||||
|
return compactQuery(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lance un fetch et applique la borne haute si necessaire. Si la page
|
||||||
|
* courante depasse `totalPages` apres l'application des filtres (cas
|
||||||
|
* « j'etais en page 5, je filtre, il ne reste qu'une page »), on
|
||||||
|
* rappelle l'API sur la derniere page valide. Un seul niveau de retry
|
||||||
|
* pour eviter une boucle si l'API renvoie des resultats incoherents.
|
||||||
|
*/
|
||||||
|
async function fetch(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.get<HydraCollection<T>>(
|
||||||
|
options.url,
|
||||||
|
buildQuery(),
|
||||||
|
{ toast: false, headers: JSONLD_HEADERS },
|
||||||
|
)
|
||||||
|
items.value = data.member ?? []
|
||||||
|
totalItems.value = data.totalItems ?? 0
|
||||||
|
|
||||||
|
const tp = totalItems.value > 0
|
||||||
|
? Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value))
|
||||||
|
: 1
|
||||||
|
|
||||||
|
// Si on est hors borne ET qu'il y a au moins une page valide
|
||||||
|
// a viser, on retombe sur la derniere page (cf. cas limite
|
||||||
|
// « page hors borne apres filtre » de la spec #73). On ne
|
||||||
|
// refetch que si la nouvelle page est differente, sinon
|
||||||
|
// boucle infinie potentielle.
|
||||||
|
if (currentPage.value > tp && tp >= 1 && totalItems.value > 0) {
|
||||||
|
currentPage.value = tp
|
||||||
|
const data2 = await api.get<HydraCollection<T>>(
|
||||||
|
options.url,
|
||||||
|
buildQuery(),
|
||||||
|
{ toast: false, headers: JSONLD_HEADERS },
|
||||||
|
)
|
||||||
|
items.value = data2.member ?? []
|
||||||
|
totalItems.value = data2.totalItems ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFetched.value = true
|
||||||
|
} catch {
|
||||||
|
// Swallow volontaire : on remet la liste a vide pour ne pas
|
||||||
|
// afficher de donnees stale. Le composant parent decide de
|
||||||
|
// l'UX (toast / message d'erreur) — pas d'a-priori ici.
|
||||||
|
items.value = []
|
||||||
|
totalItems.value = 0
|
||||||
|
hasFetched.value = true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToPage(page: number): Promise<void> {
|
||||||
|
const tp = totalPages.value
|
||||||
|
const next = Math.max(1, Math.min(page, tp))
|
||||||
|
if (next === currentPage.value) return
|
||||||
|
currentPage.value = next
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextPage(): Promise<void> {
|
||||||
|
if (currentPage.value >= totalPages.value) return
|
||||||
|
await goToPage(currentPage.value + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prevPage(): Promise<void> {
|
||||||
|
if (currentPage.value <= 1) return
|
||||||
|
await goToPage(currentPage.value - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setItemsPerPage(value: number): Promise<void> {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return
|
||||||
|
const rounded = Math.floor(value)
|
||||||
|
if (rounded === itemsPerPage.value) return
|
||||||
|
itemsPerPage.value = rounded
|
||||||
|
currentPage.value = 1
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `replace: false` (defaut) fusionne avec les filtres courants. Une
|
||||||
|
* valeur explicitement `undefined` retire la cle (utile pour effacer
|
||||||
|
* un filtre depuis un champ controle). `replace: true` remplace
|
||||||
|
* integralement l'objet par `next`.
|
||||||
|
*/
|
||||||
|
async function setFilters(next: Partial<F>, opts?: { replace?: boolean }): Promise<void> {
|
||||||
|
if (opts?.replace) {
|
||||||
|
filters.value = { ...(next as F) }
|
||||||
|
} else {
|
||||||
|
const merged = { ...filters.value, ...next } as F
|
||||||
|
// Supprime les cles explicitement passees a undefined : sans ce
|
||||||
|
// nettoyage, l'objet `filters` accumulerait des cles fantomes.
|
||||||
|
for (const key of Object.keys(next)) {
|
||||||
|
if (next[key as keyof F] === undefined) {
|
||||||
|
delete (merged as Record<string, unknown>)[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filters.value = merged
|
||||||
|
}
|
||||||
|
currentPage.value = 1
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setSort(next: SortSpec | null): Promise<void> {
|
||||||
|
sort.value = next ? { ...next } : null
|
||||||
|
currentPage.value = 1
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reset(): Promise<void> {
|
||||||
|
filters.value = { ...initialFilters }
|
||||||
|
sort.value = initialSort ? { ...initialSort } : null
|
||||||
|
itemsPerPage.value = defaultItemsPerPage
|
||||||
|
currentPage.value = 1
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
totalPages,
|
||||||
|
loading,
|
||||||
|
isEmpty,
|
||||||
|
isSinglePage,
|
||||||
|
filters,
|
||||||
|
sort,
|
||||||
|
fetch,
|
||||||
|
goToPage,
|
||||||
|
nextPage,
|
||||||
|
prevPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
setFilters,
|
||||||
|
setSort,
|
||||||
|
reset,
|
||||||
|
refresh: fetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user