Files
malio-layer-ui/docs/superpowers/plans/2026-06-09-datatable-pagination-goto.md
T

385 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DataTable — pagination « aller à la page » — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remplacer la pagination numérotée du DataTable par une forme compacte « Préc. Page [n] / N Suiv. » avec saut de page (debounce 400 ms, Entrée immédiat, clamp).
**Architecture:** Suppression du computed `visiblePages` + des boutons numérotés/`…`. Ajout d'un champ numérique piloté par un buffer `pageInput` synchronisé sur la prop `page` ; saisie debouncée (400 ms) qui n'émet que les valeurs valides `[1,N]`, Entrée/blur committent avec clamp. Le contrat `v-model:page` est inchangé.
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, Vitest + @vue/test-utils (jsdom, fake timers pour le debounce).
**Branche :** `feature/datatable-pagination-goto` (isolée de `develop`).
**Référence spec :** `docs/superpowers/specs/2026-06-09-datatable-pagination-goto-design.md`
---
## File Structure
- **Modify** `app/components/malio/datatable/DataTable.vue` — markup pagination + état/handlers du saut de page ; labels Préc./Suiv. en français.
- **Modify** `app/components/malio/datatable/DataTable.test.ts` — retrait des tests numéros/ellipsis, ajout des tests du champ de saut.
- **Modify** `COMPONENTS.md`, `CHANGELOG.md` — doc.
- **Verify/Modify** story + playground DataTable — exemple à fort volume.
**Note hooks pré-commit :** `make pre-commit` (lint + suite complète) KNOWN FLAKY (timeouts 5000 ms sur fichiers SANS rapport). Si échec uniquement sur un timeout sans rapport → relancer une fois, sinon `git commit --no-verify`. Stager des fichiers explicites — **jamais** `git add -A` (le working tree contient des modifs locales non liées : `nuxt.config.ts`, `Checkbox.vue`, `RadioButton.vue`, playground radio — NE PAS les committer).
**GIT SAFETY (tous les agents) :** rester sur `feature/datatable-pagination-goto`. NE JAMAIS `git checkout`/`switch`/`reset`/`stash`. Uniquement `git add <fichiers>` + `git commit`.
---
## Task 1 : `DataTable.vue` — barre compacte + saut de page
**Files:** Modify `app/components/malio/datatable/DataTable.vue`
- [ ] **Step 1 : Étendre l'import vue**
Remplacer :
```ts
import { computed, useAttrs, useId } from 'vue'
```
par :
```ts
import { computed, ref, watch, onBeforeUnmount, useAttrs, useId } from 'vue'
```
- [ ] **Step 2 : Ajouter l'état du saut de page**
Juste après `const totalPages = computed(() => Math.max(1, Math.ceil(props.totalItems / props.perPage)))`, ajouter :
```ts
const PAGE_JUMP_DEBOUNCE = 400
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const pageInputId = computed(() => `${componentId.value}-page-input`)
const pageInput = ref(String(props.page))
watch(() => props.page, (p) => { pageInput.value = String(p) })
onBeforeUnmount(() => { if (debounceTimer) clearTimeout(debounceTimer) })
```
- [ ] **Step 3 : Remplacer `goToPage` et `visiblePages` par `changePage` + handlers**
Remplacer tout le bloc allant de `function goToPage(page: number) {` jusqu'à la fin du computed `visiblePages` (la `})` de fermeture de `visiblePages`, juste avant `</script>`) par :
```ts
function changePage(page: number) {
if (page >= 1 && page <= totalPages.value && page !== props.page) {
emit('update:page', page)
}
}
function onPageInput() {
pageInput.value = pageInput.value.replace(/[^0-9]/g, '')
if (debounceTimer) clearTimeout(debounceTimer)
if (pageInput.value === '') return
const n = Number(pageInput.value)
if (n >= 1 && n <= totalPages.value) {
debounceTimer = setTimeout(() => changePage(n), PAGE_JUMP_DEBOUNCE)
}
}
function commitPageInput() {
if (debounceTimer) clearTimeout(debounceTimer)
const raw = pageInput.value.trim()
const n = Number(raw)
if (raw === '' || n === 0 || Number.isNaN(n)) {
pageInput.value = String(props.page)
return
}
const clamped = Math.min(Math.max(1, Math.round(n)), totalPages.value)
changePage(clamped)
pageInput.value = String(clamped)
}
```
- [ ] **Step 4 : Mettre à jour le bouton Préc.**
Dans le `<template>`, remplacer le bloc du bouton Préc. :
```html
<MalioButton
variant="tertiary"
label="Prev"
:disabled="page <= 1"
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
aria-label="Page précédente"
data-test="prev-button"
@click="goToPage(page - 1)"
/>
```
par :
```html
<MalioButton
variant="tertiary"
label="Préc."
:disabled="page <= 1"
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
aria-label="Page précédente"
data-test="prev-button"
@click="changePage(page - 1)"
/>
```
- [ ] **Step 5 : Remplacer la boucle numéros + ellipsis par le champ de saut**
Remplacer tout le bloc `<template v-for="(p, idx) in visiblePages" :key="idx"> ... </template>` (depuis `<template v-for=` jusqu'à sa `</template>` fermante incluse) par :
```html
<span class="flex items-center gap-2 text-sm">
<label :for="pageInputId" class="text-m-muted">Page</label>
<input
:id="pageInputId"
v-model="pageInput"
type="text"
inputmode="numeric"
aria-label="Aller à la page"
data-test="page-input"
class="h-[30px] w-[58px] rounded-malio border border-m-border text-center text-sm text-m-text outline-none focus:border-m-primary"
@input="onPageInput"
@keydown.enter="commitPageInput"
@blur="commitPageInput"
>
<span class="text-m-muted">/ <span data-test="total-pages">{{ totalPages }}</span></span>
</span>
```
- [ ] **Step 6 : Mettre à jour le bouton Suiv.**
Remplacer le bloc du bouton Suiv. :
```html
<MalioButton
variant="tertiary"
label="Next"
:disabled="page >= totalPages"
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
aria-label="Page suivante"
data-test="next-button"
@click="goToPage(page + 1)"
/>
```
par :
```html
<MalioButton
variant="tertiary"
label="Suiv."
:disabled="page >= totalPages"
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
aria-label="Page suivante"
data-test="next-button"
@click="changePage(page + 1)"
/>
```
- [ ] **Step 7 : Vérifier le lint**
Run : `npm run lint`
Expected : 0 erreur sur `DataTable.vue` (plus de `visiblePages`/`goToPage` orphelins ; `ref`/`watch`/`onBeforeUnmount` utilisés).
Note : `npm run test -- DataTable.test.ts` affichera des échecs sur les tests numéros/ellipsis — attendu, mis à jour en Task 2. Ne pas « corriger » le composant pour ça.
- [ ] **Step 8 : Commit**
```bash
git add app/components/malio/datatable/DataTable.vue
git commit --no-verify -m "feat(datatable) : pagination compacte avec saut de page (Page [n] / N)"
```
(`--no-verify` : la suite DataTable est rouge jusqu'à la Task 2 ; le composant est vérifié au lint ici.)
---
## Task 2 : `DataTable.test.ts` — tests du saut de page
**Files:** Modify `app/components/malio/datatable/DataTable.test.ts`
- [ ] **Step 1 : Importer `vi`, `beforeEach`, `afterEach`**
Remplacer l'import vitest en tête de fichier :
```ts
import {describe, expect, it} from 'vitest'
```
par :
```ts
import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'
```
- [ ] **Step 2 : Supprimer les tests numéros + ellipsis devenus caducs**
Dans le `describe('Pagination', ...)`, **supprimer entièrement** ces 6 tests (ils référencent `data-test="page-N"` / l'ellipsis qui n'existent plus) :
- `it('renders all pages when totalPages <= 5', ...)`
- `it('highlights current page', ...)`
- `it('emits update:page on page button click', ...)`
- `it('shows ellipsis for truncated pages (> 5 pages)', ...)`
- `it('always shows first and last page when > 5 pages', ...)`
- `it('shows 1 neighbor on each side of current page', ...)`
Conserver tous les autres tests du bloc (`hides/shows pagination`, Préc./Suiv. disabled + emits, `pagination nav has aria-label`, prev/next aria-labels).
- [ ] **Step 3 : Ajouter le bloc de tests du saut de page**
Juste après la fermeture `})` du `describe('Pagination', ...)`, ajouter :
```ts
describe('Pagination — saut de page (champ)', () => {
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.runOnlyPendingTimers(); vi.useRealTimers() })
it('affiche la page courante et le total dans le champ', () => {
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 16 })
expect((wrapper.find('[data-test="page-input"]').element as HTMLInputElement).value).toBe('16')
expect(wrapper.find('[data-test="total-pages"]').text()).toBe('31')
})
it('émet update:page après le debounce pour une valeur valide', async () => {
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
const input = wrapper.find('[data-test="page-input"]')
await input.setValue('16')
expect(wrapper.emitted('update:page')).toBeUndefined()
vi.advanceTimersByTime(400)
expect(wrapper.emitted('update:page')?.at(-1)).toEqual([16])
})
it('n\'émet pas avant la fin du debounce', async () => {
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
await wrapper.find('[data-test="page-input"]').setValue('16')
vi.advanceTimersByTime(399)
expect(wrapper.emitted('update:page')).toBeUndefined()
})
it('Entrée applique immédiatement', async () => {
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
const input = wrapper.find('[data-test="page-input"]')
await input.setValue('16')
await input.trigger('keydown.enter')
expect(wrapper.emitted('update:page')?.at(-1)).toEqual([16])
})
it('clampe une valeur > N à la dernière page (Entrée)', async () => {
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
const input = wrapper.find('[data-test="page-input"]')
await input.setValue('50')
await input.trigger('keydown.enter')
expect(wrapper.emitted('update:page')?.at(-1)).toEqual([31])
})
it('restaure la page courante quand le champ est vidé au blur', async () => {
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 5 })
const input = wrapper.find('[data-test="page-input"]')
await input.setValue('')
await input.trigger('blur')
expect(wrapper.emitted('update:page')).toBeUndefined()
expect((input.element as HTMLInputElement).value).toBe('5')
})
it('n\'émet pas pour 0 et restaure la page courante (Entrée)', async () => {
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 5 })
const input = wrapper.find('[data-test="page-input"]')
await input.setValue('0')
await input.trigger('keydown.enter')
expect(wrapper.emitted('update:page')).toBeUndefined()
expect((input.element as HTMLInputElement).value).toBe('5')
})
it('retire les caractères non numériques à la frappe', async () => {
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
const input = wrapper.find('[data-test="page-input"]')
await input.setValue('1a2b')
expect((input.element as HTMLInputElement).value).toBe('12')
})
it('resynchronise le champ quand la prop page change', async () => {
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
await wrapper.setProps({ page: 7 })
expect((wrapper.find('[data-test="page-input"]').element as HTMLInputElement).value).toBe('7')
})
})
```
- [ ] **Step 4 : Lancer la suite**
Run : `npm run test -- DataTable.test.ts`
Expected : PASS (tests conservés + 9 nouveaux). Si un test debounce échoue, vérifier que `vi.useFakeTimers()` est bien actif (beforeEach) et que `setValue` déclenche `@input` ; logguer `(input.element as HTMLInputElement).value` au besoin. Ne pas affaiblir sans comprendre.
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/datatable/DataTable.test.ts
git commit -m "test(datatable) : champ de saut de page (debounce, Entrée, clamp)"
```
(Suite verte ici ; si `make pre-commit` flake sur des fichiers SANS rapport, relancer une fois sinon `--no-verify`. Stager uniquement le fichier de test.)
---
## Task 3 : Documentation
**Files:** Modify `COMPONENTS.md`, `CHANGELOG.md`
- [ ] **Step 1 : COMPONENTS.md (section DataTable)**
Repérer la section `## MalioDataTable` (ou `## DataTable`) dans `COMPONENTS.md`. Dans le paragraphe/au plus près de la description de la pagination, ajouter (créer une courte sous-section « Pagination » si aucune n'existe, juste après la description du composant) :
```markdown
**Pagination :** forme compacte ` Préc. Page [n] / N Suiv. `. Le champ permet le saut direct à une page : la saisie s'applique après un debounce de 400 ms (seules les valeurs `1..N` partent en cours de frappe), **Entrée** applique immédiatement, une valeur `> N` est ramenée à la dernière page, un champ vidé restaure la page courante. `v-model:page` inchangé.
```
Si la section DataTable n'existe pas dans COMPONENTS.md, ajouter ce paragraphe en note dans la section la plus proche du DataTable ; sinon, STOP et signaler.
- [ ] **Step 2 : CHANGELOG.md**
Sous `### Changed`, ajouter comme première puce :
```markdown
* DataTable : pagination compacte avec saut de page — ` Préc. Page [n] / N Suiv. ` (remplace les numéros + `…`). Saisie debouncée 400 ms, Entrée immédiat, clamp `> N` → dernière page, champ vidé → page courante. Labels `Préc.` / `Suiv.`.
```
- [ ] **Step 3 : Commit**
```bash
git add COMPONENTS.md CHANGELOG.md
git commit -m "docs(datatable) : pagination compacte avec saut de page"
```
---
## Task 4 : Story / playground — exemple à fort volume
**Files:** story + playground DataTable (chemins à confirmer).
- [ ] **Step 1 : Localiser les démos DataTable**
Run : `ls app/story/**/*atatable* .playground/pages/**/*atatable* 2>/dev/null` (et `find app/story .playground -iname "*datatable*"`).
- [ ] **Step 2 : Garantir un exemple > quelques pages**
Dans la (les) démo(s) trouvée(s), s'assurer qu'au moins un exemple a `:total-items` élevé (ex. `310`) avec un `perPage` (ex. `10`) → 31 pages, pour montrer le champ de saut. Si un tel exemple existe déjà, ne rien changer (le nouveau rendu apparaît automatiquement). Sinon, ajouter une carte/section « Gros volume » avec `v-model:page` et `:total-items="310"`.
Montrer le code exact de l'ajout dans le rapport (dépend du fichier réel). Si la démo n'utilise pas v-model:page (pagination non câblée), câbler un `ref` de page local pour que le saut soit visible.
- [ ] **Step 3 : Lint + commit**
Run : `npm run lint` (0 erreur sur les fichiers modifiés).
```bash
git add <fichiers story/playground modifiés>
git commit -m "docs(datatable) : démo pagination gros volume (saut de page)"
```
---
## Task 5 : Vérification finale
- [ ] **Step 1 :** `npm run test -- DataTable.test.ts` → PASS.
- [ ] **Step 2 :** `npm run lint` → 0 erreur.
- [ ] **Step 3 (manuel, recommandé) :** `npm run dev`, ouvrir la démo DataTable à fort volume :
- Taper `16` d'un trait → après ~400 ms, va à la page 16 (un seul chargement).
- `Entrée` → immédiat.
- `50` (sur 31 pages) + Entrée → page 31.
- Vider + cliquer ailleurs → revient au numéro courant.
- Préc./Suiv. → le champ se met à jour.
---
## Self-Review
**Spec coverage :**
- Forme compacte `Page [n] / N`, suppression numéros/ellipsis → Task 1 Steps 3, 5.
- Debounce 400 ms live (valeurs `1..N`) + Entrée immédiat → Task 1 Step 3 (`onPageInput`/`commitPageInput`), tests Task 2.
- Clamp `> N` → N ; vide/0 → restaure → Task 1 Step 3 (`commitPageInput`), tests Task 2.
- Chiffres uniquement → Task 1 Step 3 (`replace(/[^0-9]/g,'')`), test Task 2.
- Labels Préc./Suiv. FR → Task 1 Steps 4, 6.
- Sync champ ↔ prop page → Task 1 Step 2 (`watch`), test Task 2.
- Contrat `v-model:page` inchangé ; nettoyage timer (`onBeforeUnmount`) → Task 1 Steps 1-3.
- Docs + démo → Tasks 3, 4.
**Placeholder scan :** aucun TODO/TBD ; code fourni intégralement (Task 4 dépend du fichier réel, instructions explicites + garde-fou STOP).
**Type consistency :** `pageInput` (ref string), `onPageInput`/`commitPageInput`/`changePage` (handlers), `pageInputId` (computed), `PAGE_JUMP_DEBOUNCE`/`debounceTimer`. `changePage` remplace `goToPage` partout (Préc./Suiv. + saut). `data-test` (`page-input`, `total-pages`, `prev-button`, `next-button`) cohérents entre composant (Task 1) et tests (Task 2).