feat(front) : add usePaginatedList composable + paginate all admin lists via MalioDataTable
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m1s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m7s

- 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:
2026-05-29 16:40:00 +02:00
parent 0c6919201e
commit 43c3220873
9 changed files with 899 additions and 272 deletions
+47
View File
@@ -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 plafondmeme 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>
+18 -23
View File
@@ -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
+21 -20
View File
@@ -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)
}
+21 -22
View File
@@ -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,
}
}