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.
|
||||
|
||||
## 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
|
||||
|
||||
**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 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])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
* `/admin/categories` : la liste des categories et le referentiel
|
||||
* CategoryType (utilise dans le select du drawer).
|
||||
* Apres ERP-73 (composable de liste paginee), la liste des categories
|
||||
* elle-meme passe par `usePaginatedList<Category>` directement dans
|
||||
* `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` /
|
||||
* `useModules` / `useAuditLog`) : reset automatique au logout via
|
||||
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md : « composables
|
||||
* avec state singleton doivent etre reinitialises au logout »), et reset
|
||||
* explicite expose via `resetCategoriesAdmin()` appele depuis
|
||||
* `modules/core/pages/logout.vue`.
|
||||
* State singleton au niveau module : reset automatique au logout via
|
||||
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md), et reset
|
||||
* explicite via `resetCategoriesAdmin()` appele depuis logout.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 { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||
|
||||
/**
|
||||
* Dette M0 : pas de pagination serveur sur les ressources Catalog (volumetrie
|
||||
* cible ≤ 300). On force une page geante via `itemsPerPage` pour recuperer
|
||||
* toute la liste en un coup. A basculer en pagination serveur quand la
|
||||
* volumetrie reelle depassera ce plafond — meme pattern que sites.vue.
|
||||
* CategoryType est un referentiel lecture-seule (RG-1.06) avec une
|
||||
* cardinalite minuscule (≤ 5 entrees connues). On force `pagination=false`
|
||||
* pour recuperer toutes les entrees en un appel et alimenter le select du
|
||||
* 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 loading = ref(false)
|
||||
const loadingTypes = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
function resetCategoriesAdminState(): void {
|
||||
categories.value = []
|
||||
types.value = []
|
||||
loading.value = false
|
||||
loadingTypes.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// Auto-enregistrement singleton : purge le state sur 401/clearSession pour
|
||||
// eviter qu'un user suivant (connecte sur le meme onglet) voie l'etat de
|
||||
// l'ancien. Le logout volontaire (page logout.vue) appelle directement
|
||||
// `resetCategoriesAdmin()` ci-dessous.
|
||||
// Auto-enregistrement singleton : purge le state sur 401/clearSession
|
||||
// pour eviter qu'un user suivant (connecte sur le meme onglet) voie le
|
||||
// referentiel de l'ancien tenant. Le logout volontaire (page logout.vue)
|
||||
// appelle directement `resetCategoriesAdmin()` ci-dessous.
|
||||
onAuthSessionCleared(resetCategoriesAdminState)
|
||||
|
||||
export function useCategoriesAdmin() {
|
||||
const api = useApi()
|
||||
|
||||
/**
|
||||
* Charge la liste des categories. Le serveur exclut les soft-deleted par
|
||||
* defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination
|
||||
* serveur (volumetrie ≤ 300, pagination front via MalioDataTable).
|
||||
*
|
||||
* `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.
|
||||
* Charge le referentiel CategoryType. 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
|
||||
* spammer un toast — le drawer affichera l'erreur inline s'il y a lieu.
|
||||
@@ -100,7 +60,7 @@ export function useCategoriesAdmin() {
|
||||
try {
|
||||
const data = await api.get<HydraCollection<CategoryType>>(
|
||||
'/category_types',
|
||||
{ itemsPerPage: HYDRA_NO_PAGINATION },
|
||||
NO_PAGINATION_QUERY,
|
||||
{ toast: false },
|
||||
)
|
||||
types.value = data.member ?? []
|
||||
@@ -113,21 +73,18 @@ export function useCategoriesAdmin() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()` pour
|
||||
* garantir que la prochaine session reparte sur un state propre meme si
|
||||
* `clearSession()` n'a pas ete declenche (cas logout volontaire).
|
||||
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()`
|
||||
* pour garantir que la prochaine session reparte sur un state propre
|
||||
* meme si `clearSession()` n'a pas ete declenche (cas logout volontaire).
|
||||
*/
|
||||
function resetCategoriesAdmin(): void {
|
||||
resetCategoriesAdminState()
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
types,
|
||||
loading,
|
||||
loadingTypes,
|
||||
error,
|
||||
fetchAll,
|
||||
fetchTypes,
|
||||
resetCategoriesAdmin,
|
||||
}
|
||||
|
||||
@@ -13,18 +13,23 @@
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Table des categories. Affichage exhaustif (volumetrie cible
|
||||
<= 300, cf. spec § 4.1) — tri 100% serveur via CategoryProvider
|
||||
(name ASC, RG-1.10). La barre de pagination du MalioDataTable
|
||||
reste cosmetique tant qu'aucun slice client n'est cable : a
|
||||
traiter cote @malio/layer-ui le jour ou la volumetrie monte. -->
|
||||
<!-- Table des categories. Tri serveur (name ASC, RG-1.10) +
|
||||
pagination serveur via usePaginatedList (#73). Le composable
|
||||
remplace l'ancien chargement « tout en un coup » a volumetrie
|
||||
cible ≤ 300 — la pagination est desormais alignee sur la regle
|
||||
projet (toute collection paginee, regle ABSOLUE n°13). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="categoryItems"
|
||||
:total-items="categories.length"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
:row-clickable="true"
|
||||
:empty-message="t('admin.categories.noCategories')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
/>
|
||||
|
||||
<!-- Drawer creation / consultation / edition. -->
|
||||
@@ -50,13 +55,27 @@ import type { Category } from '~/modules/catalog/types/category'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { can } = usePermissions()
|
||||
const { categories, fetchAll, fetchTypes } = useCategoriesAdmin()
|
||||
const { fetchTypes } = useCategoriesAdmin()
|
||||
const { submitDelete } = useCategoryForm()
|
||||
|
||||
useHead({ title: t('admin.categories.title') })
|
||||
|
||||
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 selectedCategory = ref<Category | null>(null)
|
||||
const deleteModalOpen = ref(false)
|
||||
@@ -118,7 +137,7 @@ async function handleDelete(): Promise<void> {
|
||||
deleteModalOpen.value = false
|
||||
categoryToDelete.value = null
|
||||
drawerOpen.value = false
|
||||
await fetchAll()
|
||||
await fetchCategories()
|
||||
}
|
||||
} finally {
|
||||
deleting.value = false
|
||||
@@ -126,14 +145,14 @@ async function handleDelete(): Promise<void> {
|
||||
}
|
||||
|
||||
function onCategorySaved() {
|
||||
fetchAll()
|
||||
fetchCategories()
|
||||
}
|
||||
|
||||
// Chargement initial des deux ressources (liste + referentiel des types).
|
||||
// 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 ».
|
||||
onMounted(() => {
|
||||
fetchAll()
|
||||
fetchCategories()
|
||||
fetchTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -13,14 +13,19 @@
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Table des roles -->
|
||||
<!-- Table des roles — pagination serveur via usePaginatedList (#73). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="roleItems"
|
||||
:total-items="roles.length"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.roles.noRoles')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<template #cell-code="{ item }">
|
||||
<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') })
|
||||
|
||||
const roles = ref<Role[]>([])
|
||||
const loading = ref(false)
|
||||
// Pagination serveur via le composable partage (#73).
|
||||
const {
|
||||
items: roles,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadRoles,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
} = usePaginatedList<Role>({ url: '/roles' })
|
||||
|
||||
const columns = [
|
||||
{ key: 'label', label: t('admin.roles.table.label') },
|
||||
@@ -102,25 +116,6 @@ const deleteModalOpen = ref(false)
|
||||
const roleToDelete = ref<Role | null>(null)
|
||||
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() {
|
||||
selectedRole.value = null
|
||||
drawerOpen.value = true
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
<div>
|
||||
<PageHeader>{{ t('admin.users.title') }}</PageHeader>
|
||||
|
||||
<!-- Table des utilisateurs -->
|
||||
<!-- Table des utilisateurs — pagination serveur via usePaginatedList (#73). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="userItems"
|
||||
:total-items="users.length"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.users.noUsers')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<template #cell-admin="{ item }">
|
||||
<span
|
||||
@@ -34,15 +39,26 @@
|
||||
import type { UserListItem } from '~/shared/types/rbac'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('admin.users.title') })
|
||||
|
||||
const canManage = computed(() => can('core.users.manage'))
|
||||
|
||||
const users = ref<UserListItem[]>([])
|
||||
const loading = ref(false)
|
||||
// Pagination serveur via le composable partage (#73). Le payload `users`
|
||||
// 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 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 {
|
||||
return users.value.find(u => u.id === id)
|
||||
}
|
||||
|
||||
@@ -13,14 +13,19 @@
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Table des sites -->
|
||||
<!-- Table des sites — pagination serveur via usePaginatedList (#73). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="siteItems"
|
||||
:total-items="sites.length"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.sites.noSites')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<template #cell-color="{ item }">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
@@ -67,8 +72,20 @@ const canManage = computed(() => can('sites.manage'))
|
||||
|
||||
useHead({ title: t('admin.sites.title') })
|
||||
|
||||
const sites = ref<Site[]>([])
|
||||
const loading = ref(false)
|
||||
// Pagination serveur via le composable partage (#73). Aucun OrderFilter
|
||||
// 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 = [
|
||||
{ key: 'name', label: t('admin.sites.table.name') },
|
||||
@@ -107,24 +124,6 @@ const deleteModalOpen = ref(false)
|
||||
const siteToDelete = ref<Site | null>(null)
|
||||
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() {
|
||||
selectedSite.value = null
|
||||
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