+
@@ -121,7 +125,7 @@
-
+
{{ item.code }}
@@ -66,8 +71,17 @@ const canManage = computed(() => can('core.roles.manage'))
useHead({ title: t('admin.roles.title') })
-const roles = ref([])
-const loading = ref(false)
+// Pagination serveur via le composable partage (#73).
+const {
+ items: roles,
+ totalItems,
+ currentPage,
+ itemsPerPage,
+ itemsPerPageOptions,
+ fetch: loadRoles,
+ goToPage,
+ setItemsPerPage,
+} = usePaginatedList({ url: '/roles' })
const columns = [
{ key: 'label', label: t('admin.roles.table.label') },
@@ -102,25 +116,6 @@ const deleteModalOpen = ref(false)
const roleToDelete = ref(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
diff --git a/frontend/modules/core/pages/admin/users.vue b/frontend/modules/core/pages/admin/users.vue
index f4cc2f7..719a720 100644
--- a/frontend/modules/core/pages/admin/users.vue
+++ b/frontend/modules/core/pages/admin/users.vue
@@ -2,14 +2,19 @@
{{ t('admin.users.title') }}
-
+
can('core.users.manage'))
-const users = ref([])
-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({ url: '/users' })
+
const drawerOpen = ref(false)
const selectedUser = ref(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)
}
diff --git a/frontend/modules/core/pages/login.vue b/frontend/modules/core/pages/login.vue
index 859296a..5d0e87f 100644
--- a/frontend/modules/core/pages/login.vue
+++ b/frontend/modules/core/pages/login.vue
@@ -6,7 +6,7 @@
diff --git a/frontend/modules/sites/components/SiteDrawer.vue b/frontend/modules/sites/components/SiteDrawer.vue
index 87b2dea..9be6fb6 100644
--- a/frontend/modules/sites/components/SiteDrawer.vue
+++ b/frontend/modules/sites/components/SiteDrawer.vue
@@ -11,7 +11,7 @@
{{ isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite') }}
-
{{ t('admin.sites.form.colorInvalid') }}
@@ -87,20 +92,20 @@
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
- button-class="w-[150px]"
+ button-class="w-m-btn-action"
@click="emit('delete')"
/>
diff --git a/frontend/modules/sites/pages/admin/sites.vue b/frontend/modules/sites/pages/admin/sites.vue
index c30abfc..6bb5f93 100644
--- a/frontend/modules/sites/pages/admin/sites.vue
+++ b/frontend/modules/sites/pages/admin/sites.vue
@@ -13,14 +13,19 @@
-
+
@@ -67,8 +72,20 @@ const canManage = computed(() => can('sites.manage'))
useHead({ title: t('admin.sites.title') })
-const sites = ref([])
-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({ url: '/sites' })
const columns = [
{ key: 'name', label: t('admin.sites.table.name') },
@@ -107,24 +124,6 @@ const deleteModalOpen = ref(false)
const siteToDelete = ref(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
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index cf060ff..f65e20f 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -7,7 +7,7 @@
"name": "starseed-frontend",
"hasInstallScript": true,
"dependencies": {
- "@malio/layer-ui": "^1.7.1",
+ "@malio/layer-ui": "^1.7.3",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -1866,9 +1866,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
- "version": "1.7.1",
- "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.1/layer-ui-1.7.1.tgz",
- "integrity": "sha512-RYMMappWt/fgjD+BM7//h2O6kxD6WH9Fui8hoC29xtKySRQsqD61XKTdR7BRRkpktbxKmV39q/hblyAFBqV5yw==",
+ "version": "1.7.3",
+ "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.3/layer-ui-1.7.3.tgz",
+ "integrity": "sha512-jw3ka0Az6Jf0F9ifsooknkwXph8TNgoe6H3CjF8tbBxl8oND8HLHjlZ04ooUCoOUEIlsQ1Mm2hFFlQRCB04qdA==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
diff --git a/frontend/package.json b/frontend/package.json
index 6cf10d8..dbb651c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
- "@malio/layer-ui": "^1.7.1",
+ "@malio/layer-ui": "^1.7.3",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
diff --git a/frontend/shared/composables/__tests__/usePaginatedList.test.ts b/frontend/shared/composables/__tests__/usePaginatedList.test.ts
new file mode 100644
index 0000000..9e6b3c1
--- /dev/null
+++ b/frontend/shared/composables/__tests__/usePaginatedList.test.ts
@@ -0,0 +1,412 @@
+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({
+ 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({
+ 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
+ expect(q.name).toBeUndefined()
+ })
+
+ it('setFilters({ replace: true }) remplace integralement', async () => {
+ mockResponse([], 100)
+ const list = usePaginatedList({
+ 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
+ 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
+ expect(q['order[name]']).toBe('asc')
+
+ mockResponse([], 100)
+ await list.setSort(null)
+ q = mockApiGet.mock.calls.at(-1)?.[1] as Record
+ expect(q['order[name]']).toBeUndefined()
+ })
+
+ it('setFilters retombe en page 1 (cas standard, pas le hors-borne)', async () => {
+ // setFilters remet toujours page=1 avant de refetcher : ce n'est
+ // donc PAS le chemin de retry hors-borne (couvert par le test
+ // suivant via un refetch a page constante). On verifie juste le
+ // reset de page ici.
+ mockResponse([], 50) // 5 pages
+ const list = usePaginatedList({ url: '/users' })
+ await list.fetch()
+ mockResponse([], 50)
+ await list.goToPage(5)
+ expect(list.currentPage.value).toBe(5)
+
+ mockResponse([{ id: 1 }, { id: 2 }], 12)
+ await list.setFilters({ active: true } as never)
+
+ expect(list.currentPage.value).toBe(1)
+ expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 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({
+ 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 () => {
+ const boom = new Error('boom')
+ mockApiGet.mockRejectedValueOnce(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)
+ // ... mais `error` est expose pour distinguer « vide » d'« echec ».
+ expect(list.error.value).toBe(boom)
+ })
+
+ it('error est remis a null des qu\'un fetch ulterieur reussit', async () => {
+ mockApiGet.mockRejectedValueOnce(new Error('boom'))
+ const list = usePaginatedList({ url: '/users' })
+ await list.fetch()
+ expect(list.error.value).toBeInstanceOf(Error)
+
+ mockResponse([{ id: 1 }], 1)
+ await list.fetch()
+ expect(list.error.value).toBeNull()
+ expect(list.items.value).toEqual([{ id: 1 }])
+ })
+
+ it('ignore une reponse periemee : la derniere requete *demandee* gagne', async () => {
+ // Deux fetch concurrents : le 1er resout APRES le 2eme. Sans garde
+ // de sequence, la reponse arrivee en dernier (token 1) ecraserait
+ // les donnees plus fraiches du token 2. Avec la garde, token 2 fait
+ // foi quel que soit l'ordre d'arrivee reseau.
+ const list = usePaginatedList<{ id: number }>({ url: '/users' })
+
+ let resolveSlow!: (v: unknown) => void
+ const slow = new Promise((r) => { resolveSlow = r })
+ // 1er appel : reponse lente (en vol).
+ mockApiGet.mockReturnValueOnce(slow)
+ // 2eme appel : reponse immediate avec des donnees plus fraiches.
+ mockApiGet.mockResolvedValueOnce({ member: [{ id: 2 }], totalItems: 30 })
+
+ const p1 = list.fetch() // token 1, en vol
+ const p2 = list.fetch() // token 2, resout tout de suite
+ await p2
+ expect(list.items.value).toEqual([{ id: 2 }])
+
+ // La reponse lente du token 1 arrive enfin : elle doit etre ignoree.
+ resolveSlow({ member: [{ id: 1 }], totalItems: 30 })
+ await p1
+ expect(list.items.value).toEqual([{ id: 2 }])
+ // Le spinner reste eteint (la requete recente l'avait deja coupe).
+ expect(list.loading.value).toBe(false)
+ })
+
+ 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({
+ url: '/users',
+ defaultFilters: { name: '', q: undefined } as never,
+ })
+ await list.fetch()
+
+ const q = mockApiGet.mock.calls[0][1] as Record
+ 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 }])
+ })
+})
diff --git a/frontend/shared/composables/usePaginatedList.ts b/frontend/shared/composables/usePaginatedList.ts
new file mode 100644
index 0000000..9115363
--- /dev/null
+++ b/frontend/shared/composables/usePaginatedList.ts
@@ -0,0 +1,364 @@
+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
+
+export interface UsePaginatedListOptions {
+ /** 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. **Snapshot statique** : l'objet
+ * est lu tel quel a chaque `fetch()`, ses valeurs ne sont pas deballees. Ne
+ * pas y passer de `ref` / `computed` (elles seraient serialisees comme objet,
+ * pas comme valeur) — pour un extra reactif, muter les filtres via
+ * `setFilters` ou ouvrir un ticket pour un support `MaybeRefOrGetter`.
+ */
+ extraQuery?: Record
+}
+
+export interface UsePaginatedListReturn {
+ /** Items de la page courante. */
+ items: Ref
+ /** Total d'items (toutes pages) renvoye par Hydra. */
+ totalItems: Ref
+ /** Page courante (1-based). */
+ currentPage: Ref
+ /** Taille de page courante. */
+ itemsPerPage: Ref
+ /** Options exposees au selecteur items/page. */
+ itemsPerPageOptions: Ref
+ /** Nombre total de pages (≥ 1). */
+ totalPages: Ref
+ /** Indicateur de chargement (vrai pendant `fetch()`). */
+ loading: Ref
+ /**
+ * Derniere erreur de `fetch()` (null si le dernier appel a abouti).
+ * Permet a la page de distinguer « liste reellement vide » d'un echec
+ * reseau / 403 : sans ca, `isEmpty` confond les deux cas (la liste
+ * tombe a 0 item dans les deux situations). La page decide de l'UX
+ * (bandeau, bouton reessayer) — le composable ne toaste pas.
+ */
+ error: Ref
+ /** Vrai apres au moins un fetch reussi avec 0 item. */
+ isEmpty: Ref
+ /** Vrai si la collection tient en une seule page (totalPages <= 1). */
+ isSinglePage: Ref
+ /** Filtres courants (mutation via `setFilters`). */
+ filters: Ref
+ /** Tri courant (mutation via `setSort`). */
+ sort: Ref
+ /** Lance un fetch contre l'API et met a jour items/totalItems. */
+ fetch: () => Promise
+ /** Va a la page demandee (bornes appliquees : 1 ≤ p ≤ totalPages). */
+ goToPage: (page: number) => Promise
+ /** Page suivante (no-op si deja en derniere page). */
+ nextPage: () => Promise
+ /** Page precedente (no-op si deja en premiere page). */
+ prevPage: () => Promise
+ /** Change la taille de page et revient en page 1. */
+ setItemsPerPage: (value: number) => Promise
+ /** Applique de nouveaux filtres et revient en page 1. */
+ setFilters: (next: Partial, options?: { replace?: boolean }) => Promise
+ /** Change le tri et revient en page 1. */
+ setSort: (next: SortSpec | null) => Promise
+ /** Reinitialise filtres + tri + page sur les valeurs par defaut. */
+ reset: () => Promise
+ /** Alias de `fetch()` (intention plus claire dans certains contextes). */
+ refresh: () => Promise
+}
+
+/**
+ * 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): Record {
+ const out: Record = {}
+ 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(
+ options: UsePaginatedListOptions,
+): UsePaginatedListReturn {
+ const api = useApi()
+
+ const defaultItemsPerPage = options.defaultItemsPerPage ?? 10
+ const initialFilters = { ...(options.defaultFilters ?? ({} as F)) } as F
+ const initialSort = options.defaultSort ?? null
+
+ const items = ref([]) as Ref
+ const totalItems = ref(0)
+ const currentPage = ref(1)
+ const itemsPerPage = ref(defaultItemsPerPage)
+ const itemsPerPageOptions = ref(options.itemsPerPageOptions ?? [10, 25, 50])
+ const loading = ref(false)
+ const error = ref(null)
+ // Jeton de sequence : incremente a chaque `fetch()`. Une reponse dont
+ // le jeton n'est plus le dernier est ignoree (protection contre les
+ // reponses periemes quand l'utilisateur enchaine page / tri / filtres
+ // plus vite que le reseau ne repond — sinon la derniere reponse
+ // *arrivee* gagnerait au lieu de la derniere *demandee*).
+ let fetchToken = 0
+ // `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
+ const sort = ref(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 : extras + filtres, puis
+ * pagination + tri. Les cles reservees (`page`, `itemsPerPage`,
+ * `order[...]`) sont assignees **en dernier** pour qu'un filtre ou un
+ * extra portant le meme nom ne puisse pas ecraser silencieusement la
+ * pagination. Les filtres `null`/`undefined`/'' sont elimines pour ne
+ * pas polluer l'URL.
+ */
+ function buildQuery(): Record {
+ const query: Record = {}
+ if (options.extraQuery) {
+ Object.assign(query, options.extraQuery)
+ }
+ Object.assign(query, filters.value)
+ // Cles reservees en dernier : priorite a la pagination/au tri.
+ query.page = currentPage.value
+ query.itemsPerPage = itemsPerPage.value
+ if (sort.value) {
+ // Format API Platform : ?order[field]=asc
+ query[`order[${sort.value.field}]`] = sort.value.direction
+ }
+ 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 {
+ const token = ++fetchToken
+ loading.value = true
+ error.value = null
+ try {
+ const data = await api.get>(
+ options.url,
+ buildQuery(),
+ { toast: false, headers: JSONLD_HEADERS },
+ )
+ // Une requete plus recente a ete lancee entre-temps : on jette
+ // cette reponse pour ne pas ecraser des donnees plus fraiches.
+ if (token !== fetchToken) return
+ 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>(
+ options.url,
+ buildQuery(),
+ { toast: false, headers: JSONLD_HEADERS },
+ )
+ // Meme garde apres le refetch hors-borne.
+ if (token !== fetchToken) return
+ items.value = data2.member ?? []
+ totalItems.value = data2.totalItems ?? 0
+ }
+
+ hasFetched.value = true
+ } catch (e) {
+ // Reponse periemee : ne pas toucher au state, une requete plus
+ // recente est en cours et fera foi.
+ if (token !== fetchToken) return
+ // Swallow volontaire : on remet la liste a vide pour ne pas
+ // afficher de donnees stale, et on expose l'erreur pour que la
+ // page distingue « vide » d'« echec ». Le composant parent
+ // decide de l'UX (toast / message d'erreur) — pas d'a-priori ici.
+ error.value = e
+ items.value = []
+ totalItems.value = 0
+ hasFetched.value = true
+ } finally {
+ // Seule la requete la plus recente eteint le spinner : une
+ // reponse periemee ne doit pas le couper alors qu'un fetch plus
+ // recent est encore en vol.
+ if (token === fetchToken) loading.value = false
+ }
+ }
+
+ async function goToPage(page: number): Promise {
+ 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 {
+ if (currentPage.value >= totalPages.value) return
+ await goToPage(currentPage.value + 1)
+ }
+
+ async function prevPage(): Promise {
+ if (currentPage.value <= 1) return
+ await goToPage(currentPage.value - 1)
+ }
+
+ async function setItemsPerPage(value: number): Promise {
+ 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, opts?: { replace?: boolean }): Promise {
+ 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)[key]
+ }
+ }
+ filters.value = merged
+ }
+ currentPage.value = 1
+ await fetch()
+ }
+
+ async function setSort(next: SortSpec | null): Promise {
+ sort.value = next ? { ...next } : null
+ currentPage.value = 1
+ await fetch()
+ }
+
+ async function reset(): Promise {
+ filters.value = { ...initialFilters }
+ sort.value = initialSort ? { ...initialSort } : null
+ itemsPerPage.value = defaultItemsPerPage
+ currentPage.value = 1
+ await fetch()
+ }
+
+ return {
+ items,
+ totalItems,
+ currentPage,
+ itemsPerPage,
+ itemsPerPageOptions,
+ totalPages,
+ loading,
+ error,
+ isEmpty,
+ isSinglePage,
+ filters,
+ sort,
+ fetch,
+ goToPage,
+ nextPage,
+ prevPage,
+ setItemsPerPage,
+ setFilters,
+ setSort,
+ reset,
+ refresh: fetch,
+ }
+}