From 4d3879156da0267d4430ddbcac7577cf8e157243 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 15 Jun 2026 11:07:59 +0200 Subject: [PATCH] =?UTF-8?q?fix(pagination)=20:=20=C3=A9viter=20la=20tronca?= =?UTF-8?q?ture=20silencieuse=20des=20collections=20pagin=C3=A9es=20(LST-5?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API Platform pagine par défaut à 30 éléments/page et le helper front extractHydraMembers ne lit que la première page (il ignore hydra:view.next), ce qui tronque silencieusement toute liste de plus de 30 éléments. - Back : paginationEnabled false sur les GetCollection consommées en entier et à volume borné/modéré (Client, Project, User, TaskTag, TaskGroup, TaskStatus, TaskPriority, TaskEffort, Workflow). - Front : nouveau helper fetchAllHydra() qui parcourt toutes les pages ; utilisé pour /notifications (volume non borné, reste paginé côté back). - Doc : règle anti-troncature ajoutée au CLAUDE.md. Déjà protégés (vérifiés) : Task, TimeEntry, TaskDocument, TaskRecurrence, AbsenceRequest/Policy/Balance (paginationEnabled false) et /time_entries/range. --- CLAUDE.md | 1 + frontend/services/notifications.ts | 9 ++++--- frontend/utils/api.ts | 43 ++++++++++++++++++++++++++++++ src/Entity/Client.php | 2 +- src/Entity/Project.php | 2 +- src/Entity/TaskEffort.php | 2 +- src/Entity/TaskGroup.php | 2 +- src/Entity/TaskPriority.php | 2 +- src/Entity/TaskStatus.php | 2 +- src/Entity/TaskTag.php | 2 +- src/Entity/User.php | 1 + src/Entity/Workflow.php | 2 +- 12 files changed, 59 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d00220a..5e7f8d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` - Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`) - 4 espaces d'indentation - MalioSelect : options `{ label: string, value: string | number | null }` — accepte les valeurs **string** (enums string OK, ex `category`/`StatusCategory`), pas seulement `number` (vérifié dans la source `Select.vue` : `modelValue: string | number | null`). L'option vide `null` n'est ajoutée que si `empty-option-label` est passé (ne pas le passer pour un champ requis). Largeur via `group-class` (pas de prop `minWidth`/`min-width`). ⚠️ Le `COMPONENTS.md` de la lib est inexact sur ce composant (il indique une clé `text` et une prop `minWidth` inexistantes) : la clé d'affichage réelle est `label`. Ne jamais modifier la lib `malio-layer-ui` depuis ce projet. +- **Pagination API Platform & `extractHydraMembers` (piège de troncature)** : API Platform pagine par défaut à **30 éléments/page**. Le helper `extractHydraMembers()` (`frontend/utils/api.ts`) ne lit **que la première page** (il ignore `hydra:view.next`) → toute liste > 30 éléments est tronquée **silencieusement** (bug LST-51/LST-52). Règle : toute collection consommée via `extractHydraMembers` doit **soit** être servie par une ressource non paginée (`paginationEnabled: false` sur le `GetCollection`, quand le volume est borné/modéré et qu'on veut tout afficher — c'est le cas des référentiels et de Client/Project/User/Task/TimeEntry), **soit** gérer explicitement la pagination via le helper `fetchAllHydra()` (suit toutes les pages, à réserver aux volumes non bornés comme `/notifications`), **soit** passer par une route dédiée bornée (ex `/time_entries/range`). Ne **jamais** lire une seule page d'une collection potentiellement > 30 éléments. ### Composants UI diff --git a/frontend/services/notifications.ts b/frontend/services/notifications.ts index 1417259..c284a7e 100644 --- a/frontend/services/notifications.ts +++ b/frontend/services/notifications.ts @@ -1,13 +1,16 @@ import type { Notification } from './dto/notification' import type { HydraCollection } from '~/utils/api' -import { extractHydraMembers } from '~/utils/api' +import { fetchAllHydra } from '~/utils/api' export function useNotificationService() { const api = useApi() async function getAll(): Promise { - const data = await api.get>('/notifications') - return extractHydraMembers(data) + // La ressource /notifications reste paginée côté back (volume non borné) : + // on suit toutes les pages pour ne pas tronquer la liste à 30 éléments. + return fetchAllHydra(page => + api.get>('/notifications', { page }), + ) } async function markAsRead(id: number): Promise { diff --git a/frontend/utils/api.ts b/frontend/utils/api.ts index 00c033b..6e673ec 100644 --- a/frontend/utils/api.ts +++ b/frontend/utils/api.ts @@ -8,3 +8,46 @@ export type HydraCollection = { export function extractHydraMembers(response: HydraCollection): T[] { return response['hydra:member'] ?? response['member'] ?? [] } + +function extractHydraTotal(response: HydraCollection): number | undefined { + return response['hydra:totalItems'] ?? response['totalItems'] +} + +/** + * Récupère TOUS les éléments d'une collection Hydra paginée en parcourant les pages. + * + * `extractHydraMembers` ne lit que la première page (30 éléments par défaut côté + * API Platform) : toute liste plus longue est tronquée silencieusement. Utiliser + * ce helper dès qu'une collection potentiellement > 30 éléments doit être + * affichée en entier alors que sa ressource back reste paginée. + * + * `fetchPage` reçoit le numéro de page (1-indexé) et doit renvoyer la collection + * Hydra correspondante (passer `page` en query param de l'appel API). + * + * @param maxPages garde-fou anti-boucle infinie (par défaut 1000 pages). + */ +export async function fetchAllHydra( + fetchPage: (page: number) => Promise>, + maxPages = 1000, +): Promise { + const first = await fetchPage(1) + const all = extractHydraMembers(first) + const total = extractHydraTotal(first) + + if (total === undefined) { + return all + } + + let page = 2 + while (all.length < total && page <= maxPages) { + const next = await fetchPage(page) + const members = extractHydraMembers(next) + if (members.length === 0) { + break + } + all.push(...members) + page += 1 + } + + return all +} diff --git a/src/Entity/Client.php b/src/Entity/Client.php index 1a2e6b7..dd782b2 100644 --- a/src/Entity/Client.php +++ b/src/Entity/Client.php @@ -18,7 +18,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 5d58c84..6b5267b 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -25,7 +25,7 @@ use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post( security: "is_granted('ROLE_ADMIN')", diff --git a/src/Entity/TaskEffort.php b/src/Entity/TaskEffort.php index a656725..04ce397 100644 --- a/src/Entity/TaskEffort.php +++ b/src/Entity/TaskEffort.php @@ -16,7 +16,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/TaskGroup.php b/src/Entity/TaskGroup.php index 753c9d1..8a5ecf8 100644 --- a/src/Entity/TaskGroup.php +++ b/src/Entity/TaskGroup.php @@ -19,7 +19,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/TaskPriority.php b/src/Entity/TaskPriority.php index 48da256..c960977 100644 --- a/src/Entity/TaskPriority.php +++ b/src/Entity/TaskPriority.php @@ -16,7 +16,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/TaskStatus.php b/src/Entity/TaskStatus.php index aa69787..021afdc 100644 --- a/src/Entity/TaskStatus.php +++ b/src/Entity/TaskStatus.php @@ -18,7 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/TaskTag.php b/src/Entity/TaskTag.php index 537db3a..4f3a864 100644 --- a/src/Entity/TaskTag.php +++ b/src/Entity/TaskTag.php @@ -16,7 +16,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/User.php b/src/Entity/User.php index fd75f39..2bc49c9 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -33,6 +33,7 @@ use Symfony\Component\Serializer\Attribute\Groups; normalizationContext: ['groups' => ['user:list']], ), new GetCollection( + paginationEnabled: false, normalizationContext: ['groups' => ['user:list']], ), new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), diff --git a/src/Entity/Workflow.php b/src/Entity/Workflow.php index 80db9b3..1b3ffff 100644 --- a/src/Entity/Workflow.php +++ b/src/Entity/Workflow.php @@ -21,7 +21,7 @@ use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"),