From e83f53b9975b2c16e9aa9e26f943684d2b20b670 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Wed, 14 Oct 2026 13:29:30 -0300 Subject: [PATCH 01/24] =?UTF-8?q?feat(admin-frontend):=20pagina=C3=A7?= =?UTF-8?q?=C3=A3o=20da=20lista=20de=20pessoas=20atendidas=20(#155,=20fron?= =?UTF-8?q?tend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lista mostrava só a primeira página (20 pessoas) e nada na tela dizia que havia mais. Agora ela pagina, com peças que qualquer lista paginada reaproveita (ADR 0068). - Página e tamanho ficam na URL (?page=, ?pageSize=), sem os valores padrão. Buscar volta à página 1 e mantém o tamanho; Voltar e Avançar percorrem as páginas. - Rodapé: "Exibindo 21–40 de 87 pessoas", "Itens por página" (10, 20, 50, 100; padrão 20) e o paginador com números, reticências, Anterior e Próxima ("Página 5 de 22" no celular). Enquanto carrega, o esqueleto toma o lugar da tabela e o rodapé fica desabilitado no último total conhecido. - Página ilegível na URL, ou além da última (o backend responde 200 com lista vazia), é corrigida com replace, sem aviso, e nunca vira "Nenhuma pessoa ainda". - shared/pagination (funções puras e PagedResult), shared/ui/select (Base UI, via shadcn, só as peças usadas), widgets/pagination e a prop pagination no ListSection. Cada hook de lista liga o useSearchParams às funções puras. - clientsRepository.list passa a enviar sempre Page e PageSize, e ClientList vira PagedResult. - Um widget pode importar outro widget (list-section renderiza pagination): emenda a ARCHITECTURE §1 e o ADR 0042. O ADR 0068 registra as decisões e as opções descartadas; o ADR 0067 aponta para ele. - Testes: lógica pura, widget, ListSection, repositório e página (com um backend falso que pagina de verdade); e2e com 6 cenários novos contra a stack real. Co-Authored-By: Claude Sonnet 5.5 --- .claude/skills/agenza-frontend-slice/SKILL.md | 4 +- apps/admin-frontend/docs/ARCHITECTURE.md | 56 ++- apps/admin-frontend/e2e/clients.spec.ts | 111 ++++- apps/admin-frontend/e2e/helpers.ts | 40 ++ .../clients/api/clientsRepository.test.ts | 38 +- .../features/clients/api/clientsRepository.ts | 12 +- .../src/features/clients/model/clientList.ts | 8 +- .../ClientListPage/ClientListPage.test.tsx | 450 +++++++++++++++--- .../pages/ClientListPage/ClientListPage.tsx | 7 + .../pages/ClientListPage/useClientListPage.ts | 65 ++- .../ClientListPage/useClientListPage.types.ts | 10 +- .../src/shared/pagination/paging.test.ts | 158 ++++++ .../src/shared/pagination/paging.ts | 81 ++++ .../ui/select/components/select-content.tsx | 43 ++ .../ui/select/components/select-item.tsx | 29 ++ .../select/components/select-primitives.tsx | 24 + .../ui/select/components/select-trigger.tsx | 23 + .../src/shared/ui/select/index.tsx | 10 + .../src/widgets/list-section/index.tsx | 8 +- .../list-section/list-section.test.tsx | 119 +++++ .../list-section/list-section.types.ts | 5 + .../components/pagination-page-size.tsx | 38 ++ .../components/pagination-pages.tsx | 62 +++ .../components/pagination-summary.tsx | 23 + .../src/widgets/pagination/index.tsx | 50 ++ .../widgets/pagination/pagination.test.tsx | 249 ++++++++++ .../widgets/pagination/pagination.types.ts | 31 ++ ...widgets-layer-and-layer-boundaries-rule.md | 7 +- docs/adr/0067-admin-frontend-clients-list.md | 8 +- .../0068-admin-frontend-list-pagination.md | 100 ++++ docs/adr/README.md | 5 +- 31 files changed, 1749 insertions(+), 125 deletions(-) create mode 100644 apps/admin-frontend/src/shared/pagination/paging.test.ts create mode 100644 apps/admin-frontend/src/shared/pagination/paging.ts create mode 100644 apps/admin-frontend/src/shared/ui/select/components/select-content.tsx create mode 100644 apps/admin-frontend/src/shared/ui/select/components/select-item.tsx create mode 100644 apps/admin-frontend/src/shared/ui/select/components/select-primitives.tsx create mode 100644 apps/admin-frontend/src/shared/ui/select/components/select-trigger.tsx create mode 100644 apps/admin-frontend/src/shared/ui/select/index.tsx create mode 100644 apps/admin-frontend/src/widgets/pagination/components/pagination-page-size.tsx create mode 100644 apps/admin-frontend/src/widgets/pagination/components/pagination-pages.tsx create mode 100644 apps/admin-frontend/src/widgets/pagination/components/pagination-summary.tsx create mode 100644 apps/admin-frontend/src/widgets/pagination/index.tsx create mode 100644 apps/admin-frontend/src/widgets/pagination/pagination.test.tsx create mode 100644 apps/admin-frontend/src/widgets/pagination/pagination.types.ts create mode 100644 docs/adr/0068-admin-frontend-list-pagination.md diff --git a/.claude/skills/agenza-frontend-slice/SKILL.md b/.claude/skills/agenza-frontend-slice/SKILL.md index 6c0b7974..2a0de45c 100644 --- a/.claude/skills/agenza-frontend-slice/SKILL.md +++ b/.claude/skills/agenza-frontend-slice/SKILL.md @@ -56,8 +56,8 @@ enforces it together with the layer direction. It only inspects files inside `ap `widgets/` and `shared/` — `src/main.tsx`, `src/test/`, `e2e/` and config files are on review. Direction is `app → features → widgets → shared`, and inside a slice `ui → model | api`, -`api → model`. A domain type never lives in `api/`. `widgets/` imports only `shared/`; `shared/` -imports none of the others. +`api → model`. A domain type never lives in `api/`. `widgets/` imports `shared/` and, to compose one, another widget (never a cycle); +`shared/` imports none of the others. ## 3. Route wiring diff --git a/apps/admin-frontend/docs/ARCHITECTURE.md b/apps/admin-frontend/docs/ARCHITECTURE.md index ffd67adc..66ae2f8e 100644 --- a/apps/admin-frontend/docs/ARCHITECTURE.md +++ b/apps/admin-frontend/docs/ARCHITECTURE.md @@ -33,8 +33,9 @@ src/ │ │ └── Paths.ts The slice's own URL, written once (CLIENTS_PATH, TAGS_PATH) │ └── index.ts The slice's ONLY public surface ├── widgets/ Generic UI compositions with behaviour but no domain (confirm-dialog, -│ list-section, page-header, page-section, form-footer); import -│ shared/ only, consumed by features (ADR 0042) +│ list-section, page-header, page-section, form-footer, pagination); +│ import shared/ and, to compose one, another widget; consumed by +│ features (ADR 0042, ADR 0068) └── shared/ Cross-cutting, no business logic ├── api/ servicesFacade, servicesApi (its composition), apiClient, │ unwrap (Result → exception at the framework boundary), formErrors @@ -49,6 +50,9 @@ src/ ├── pubsub/ Generic createTopic/useTopic — publish/subscribe with a payload, no │ domain knowledge; a concrete topic (e.g. tagDeleted) lives in the │ feature that owns it, never here + ├── pagination/ paging.ts: the ?page= / ?pageSize= contract of every paged list as pure + │ functions (read and write them in a URLSearchParams, last page, visible + │ range, the window of page numbers) plus PagedResult; no React (§5) ├── format/ Policy-free calendar and display helpers: date.ts (utcDate, todayUtc, │ dd/mm/aaaa mask/parse/format, fullYearsBetween) and age.ts ├── values/ One file per shared value, mirroring the backend's @@ -73,7 +77,9 @@ proven by its mock-free tests, not by its folder name. **Dependency direction** — enforced, not just intended: - `app` → `features` → `widgets` → `shared`. Never up: `shared/` imports none of the other three, - `widgets/` imports only `shared/`, and `features/` never imports `app/`. + `widgets/` imports `shared/` and, to compose one, another widget (never in a cycle: `list-section` + renders `pagination`, [ADR 0068](../../../docs/adr/0068-admin-frontend-list-pagination.md)), and + `features/` never imports `app/`. - Within a slice: `ui` → `model` / `api`, and `api` → `model`. The domain entity is defined in `model/` and imported by the layers that use it — the wire layer never owns it. - **Functional core, imperative shell** — and the one `model → api` edge it still needs. The pure @@ -329,7 +335,11 @@ recovery action) — a generic, reusable table component doesn't know a per-feat possível carregar." with no code and no way to recover short of a full page reload. A feature that needs the fuller treatment passes it in; `ClientListPage` and `TagListPage` both do. Search stays page-owned too: `toolbar` is not a `list-section` prop, so a search box sits beside the component in the page's own -markup, not inside it. +markup, not inside it. Paging is the opposite case: it is `list-section`'s own optional `pagination` prop +(`{ page, pageSize, totalCount, itemLabel, onPageChange, onPageSizeChange }`, or `null`), because _when_ the +footer shows follows from `status` (never on a failure, disabled while loading, nothing without a total) and +every paged list would repeat that. The page supplies the numbers, the noun and the callbacks, as it supplies +`failure` with its `onRetry` ([ADR 0068](../../../docs/adr/0068-admin-frontend-list-pagination.md)). Full wiring detail: [`contracts/api-client-contract.md`](../specs/001-oidc-shell-scaffold/contracts/api-client-contract.md). Exemplos reais de request/response — sucesso, validação, conflito, 404, autenticação/tenant — @@ -439,9 +449,11 @@ Chosen, and — just as important — tried and backed out of, so nobody re-liti | `DateField` has no key handler of its own: the keyboard reaches the calendar through its **Abrir calendário** button (Tab, Enter or Space; Esc closes and returns focus to the button), and the arrow keys stay with the input | Asked for, in review of #174. An `onKeyDown` on the input opened the calendar on `↓`, the only key handler in `src/`, against AGENTS.md's "prefer the primitive's behaviour to a key handler of your own". `↓`-opens is the combobox pattern, which needs `role="combobox"`, `aria-expanded`, `aria-haspopup` and `aria-controls` on the input; without them a screen reader user had no way to learn it existed, and adding them would have been more hand-written behaviour, not less. It also replaced any `onKeyDown` a caller passed, since the spread came first. The input plus a button that opens the calendar as a dialog is the WAI-ARIA date picker dialog pattern, and Base UI's `PopoverTrigger` already gives it all. A test pins both halves: the keyboard path through the button, and `↓` in the input opening nothing. | | `ControlledField` takes the field's path as an optional second type argument, `ControlledField = Path>` | Asked for, in review of #174. With only `T`, `field` was typed over every path of the form, so `field.value` was the union of all its values and the reference contact's purposes needed `field.value as string[]`. Passing the path (``ControlledField``) types `field.value` as that field's own value, `ContactPurpose[]`. The default keeps every other call site as it was. The cost is the second explicit argument: TypeScript does not infer one type argument when another is given. | | A slice names its own URL once, in `ui/Paths.ts` (`CLIENTS_PATH`, `TAGS_PATH`); `app/`keeps writing the path | Asked for, in review of #174:`/pessoas`was written three times inside`features/clients`, `/tags`twice inside`features/tags`. The constant sits in `ui/`rather than in one page's folder, because the list (#155) and editing (#156) navigate to the same place.`app/routes.tsx`and`app/shell/navigation.ts`keep the literal:`app/` owns the route table, and taking the constant from the slice would mean the shell importing the slice barrel, which risks pulling a lazily loaded slice into the main bundle. Tests and e2e keep the literal too, since they check the URL contract. | -| `features/clients` — the Pessoas list (`/pessoas`, issue #155): search only, with the situation filter and the paging controls deferred | Second list screen, on the shape Tags settled: `useClientListPage` reads `?q=`, calls `clientsRepository.list(search, signal)` from a plain `useEffect` with an `AbortController`, and derives `status` by comparing the in-flight query with the last resolved one. The page is a shell over `ListSection`, with `clientColumns` (Nome, Telefone, E-mail, CPF, Situação) and, through `list-section`, `errorTitle` and `failure` for the error state, plus `search` and `emptyCopy` for the two empty states. Decided with the owner on 2026-10-13, before any code ([ADR 0067](../../../docs/adr/0067-admin-frontend-clients-list.md)): the CPF shows **whole and formatted** (`maskCpf`, which formats and hides nothing), following ADR 0063 over the text of #155 and #139; the filter and the paging controls wait for the owner to define them, so the screen lists the first page (20 people) with no sign that there are more (a "Exibindo N de M" line was built and then removed), the repository takes only `search`, and `status`/`page` arrive together with their controls; each name links to `/pessoas/:id`, a provisional "Em breve" page (`app/pages/ClientDetails.tsx`) until #167 replaces it. Empty is two states that `list-section` itself renders from the page's `emptyCopy` and `search` instead of its fixed message: nobody yet (no button, the header already has **Nova pessoa**) and a search that found nobody (**Limpar busca**). `list` forwards the whole page verbatim, like `create` (`{ items, totalCount, page, pageSize }`); the model types the three paging fields as `number`, and nothing reads them yet. The search form and the hook's URL and abort logic are the second copy of Tags' and stay copied until a third list (§6). `app/pages/Clients.tsx` is retired. Acceptance criteria of #155 not met yet: an inactive person appearing through the filter, and moving between pages. | +| `features/clients` — the Pessoas list (`/pessoas`, issue #155): search only, with the situation filter and the paging controls deferred | Second list screen, on the shape Tags settled: `useClientListPage` reads `?q=`, calls `clientsRepository.list(search, signal)` from a plain `useEffect` with an `AbortController`, and derives `status` by comparing the in-flight query with the last resolved one. The page is a shell over `ListSection`, with `clientColumns` (Nome, Telefone, E-mail, CPF, Situação) and, through `list-section`, `errorTitle` and `failure` for the error state, plus `search` and `emptyCopy` for the two empty states. Decided with the owner on 2026-10-13, before any code ([ADR 0067](../../../docs/adr/0067-admin-frontend-clients-list.md)): the CPF shows **whole and formatted** (`maskCpf`, which formats and hides nothing), following ADR 0063 over the text of #155 and #139; the filter and the paging controls wait for the owner to define them, so the screen lists the first page (20 people) with no sign that there are more (a "Exibindo N de M" line was built and then removed), the repository takes only `search`, and `status`/`page` arrive together with their controls; each name links to `/pessoas/:id`, a provisional "Em breve" page (`app/pages/ClientDetails.tsx`) until #167 replaces it. Empty is two states that `list-section` itself renders from the page's `emptyCopy` and `search` instead of its fixed message: nobody yet (no button, the header already has **Nova pessoa**) and a search that found nobody (**Limpar busca**). `list` forwards the whole page verbatim, like `create` (`{ items, totalCount, page, pageSize }`); the model types the three paging fields as `number`, and nothing reads them yet. The search form and the hook's URL and abort logic are the second copy of Tags' and stay copied until a third list (§6). `app/pages/Clients.tsx` is retired. Acceptance criteria of #155 not met yet: an inactive person appearing through the filter (moving between pages arrived right after, in the next rows). | | `shared/ui/optional-text`: one way to show a value that may be missing, with an optional `fallback` | Asked for, right after the Pessoas list, when its page-local `OptionalText` (a dash hidden from assistive tech plus "Não informado" for it) turned out to be a second spelling of what `tagColumns` already wrote by hand for the description ("Sem descrição", faded italic, nothing for a screen reader). Nothing in `shared/ui`, `widgets` or `shared/format` covered it: `EmptyState` is a block for an empty list and `shared/values/optionalValue` parses an optional form field. One component, two looks: with no `fallback` it shows the dash, so a table of fixed-format values (phone, e-mail, CPF) stays aligned and a screen reader still hears that the value is missing; with a `fallback` it shows that text, visible, faded and italic, for a free-text field where a word reads better than a dash. `className` goes to every look, so the caller decides things the component cannot know: `whitespace-nowrap` for a phone, `text-sm text-muted-foreground` for a description (the old Pessoas copy forced `nowrap`, which would have been wrong for free text). Only `null` and `undefined` count as missing, as `tag.description ?? 'Sem descrição'` already did. Pessoas and Etiquetas both use it; the Etiquetas tests are unchanged and prove the text stayed. Not excluded from the coverage gate: it has a branch per look and its own test asserts each one. | | `list-section` gains the error state and the two empty variants every list has, driven by `errorTitle` and `failure` (`{ message, code, onRetry }`), `search` (`{ term, onClear }`) and `emptyCopy`; `ClientListResults` and `ClientListEmpty` removed | Asked for, right after the Pessoas list, in three steps. First, the same "ready and zero items" condition was written twice (`ListSectionReady` for the fixed message, `ClientListResults` for the page's own), so `list-section` got `empty` and `error` slots. Then the empty slot was folded into the widget: "nothing yet" versus "the search found nothing, with **Limpar busca**" is behaviour every list has, and Tags would otherwise write a second copy. Then the error slot got the same treatment: the page was building an `ErrorState` from the failure's message, code and retry, which every list that can fail repeats. The widget still decides _when_ (`status` and `items.length`, the check that must not run before the fetch is known to have succeeded), and now _which variant_ (a non-empty `search.term`) and how a failure is laid out; the page supplies the words as ready phrases, not a noun plus a gender, because the copy cannot be derived ("Nenhuma pessoa" against "Nenhum serviço", "ativa", the e-mail and CPF hint) and a grammar helper would still need the same escape hatches. Every field is optional, so a list that passes nothing keeps "Nenhum item encontrado." and "Não foi possível carregar.". `TagListPage` moved onto `errorTitle`, `failure`, `search` and `emptyCopy` right after Pessoas ("Nenhuma etiqueta ainda"; "Nenhuma etiqueta encontrada para «x»" with **Limpar busca**), so its own `ErrorState` ternary and the generic empty message are gone, and `onClearSearch` joined its hook. Both slots were removed once the built-in variants left them without a reader, which §1 treats as dead API. This reverses the §2 sentence that neither state is a prop the page configures, and the cost it listed (no action for a per-feature "Limpar busca", no retry on a failed load). Known boundary: a status filter will make "nothing yet" depend on the filter ("Nenhuma pessoa inativa ainda"), which the page can express through `emptyCopy.title`; clearing a filter, as opposed to a search, is not covered yet. Tests assert each effect: copy and details replace the defaults in their own state, `onClear` and `onRetry` fire, the retry button exists only with an `onRetry`, and nothing shows while loading or ready. | +| `features/clients` — the Pessoas list pages (issue #155, [ADR 0068](../../../docs/adr/0068-admin-frontend-list-pagination.md)): `?page=` and `?pageSize=` in the URL, a footer under the table, `shared/pagination`, `shared/ui/select`, `widgets/pagination` and a `pagination` prop on `list-section` | Asked for right after #177 merged, with one condition: minimally generic, for the next paged list (the services API already pages; Etiquetas does not, so `TagListPage` is untouched). Decided with the owner in three rounds of questions before any code; ADR 0068 holds the answers and the options they turned down. The footer is a summary ("Exibindo 21–40 de 87 pessoas", a status region), **Itens por página** (10, 20, 50, 100; 20 by default) and a pager (**Anterior**, the numbers around the current page with gaps, **Próxima**; "Página 5 de 22" below `sm`). What shaped the code. **(1)** The rule for _when_ the footer shows follows from `status` (never on a failure, disabled while loading, nothing without a total), which every paged list would repeat, so it lives in `list-section`'s `pagination` prop the way the empty and error variants do; the page passes the numbers, the noun (`itemLabel`) and two callbacks. **(2)** The URL contract is pure functions in `shared/pagination/paging.ts` (read with a fallback to the default, write leaving the defaults and every other parameter alone, last page, visible range, the window of numbers), tested without mocks; each list's hook ties `useSearchParams` to them (about ten lines), because hooks are not shared between pages (§1) and the owner kept that rule over a generic `usePagination`. **(3)** `useClientListPage` keys its result by the whole request (`search`, `page`, `pageSize`) as the tags hook keys by `query`, keeps the last `totalCount` in its own state so the footer stays on screen, disabled, over the skeleton while the next page loads, and shows no footer before the first answer or after a failure. **(4)** A page that cannot be shown is corrected by one effect that compares the URL with its canonical form and `replace`s it: an unreadable `page` or `pageSize` reads as the default, and a page past the last one (the backend answers `200` with empty `items` and the real total) goes to the last that exists; while it moves the hook reports loading, never an empty list, because "Nenhuma pessoa ainda" would be a lie. **(5)** The pager's buttons use Base UI's `focusableWhenDisabled` (`aria-disabled`), so focus survives reaching the first or last page and a load in flight; the select only has `disabled`. `shared/ui/select` is the shadcn CLI's `select.tsx` trimmed to the five parts the footer uses, with the `cn` import rewritten, the `tw-animate-css` classes converted to `data-starting-style` / `data-ending-style`, and the stray `cn` dependency uninstalled (`package.json` and the lockfile came out unchanged). The page test serves a fake backend that pages, and answers a page past the last like the real one. | +| A widget may import another widget (`list-section` renders `pagination`); §1 and [ADR 0042](../../../docs/adr/0042-admin-frontend-widgets-layer-and-layer-boundaries-rule.md) no longer say a widget imports only `shared/` | Asked for, in the paging round: the footer had to be a widget of its own (usable without `list-section`, and inside the coverage gate) and `list-section` had to show it, which the sentence forbade. The lint rule never did: it reports only an import into a higher layer, and a sibling is the same layer. The options turned down were a `footer` slot the page fills (the visibility rule split between page and widget) and the footer inside `list-section/components/` (only `list-section` could use it, and `src/widgets/list-section/**` sits outside the coverage gate). No cycle may form: `pagination` imports `shared/` only. | The palette opens from the header button. The single-use `LinkButton` wrapper was removed; its remaining link uses `buttonVariants` directly. The combobox keeps only the parts used by the palette, and those parts are in the coverage gate. @@ -486,23 +498,23 @@ because it didn't need to be yet. This is the compiled view across the whole app ### Deliberately not built — no need yet -| Not built | Why not | Build it when | -| ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `entities/` and top-level `pages/` FSD layers | Nothing is shared across features yet | A second feature needs the same entity or page | -| Server-state library (TanStack Query, SWR) | `features/tags`'s one route proves a plain repository call — from a hook, a loader, or an action, whichever shape the screen needs that week — is enough so far | Cross-route caching, refetch-on-focus, or optimistic UI | -| Request-cancellation layer (`AbortController`) | The effect `ignore`-flag already fixes the race; nothing is slow enough to abort on the wire | Search-as-you-type, a large export | -| `toDomain(dto)` mappers | `tagsRepository` forwards `Tag`/`TagResponse` verbatim — no repository has needed one yet | A wire shape and a domain type genuinely diverge | -| Ports/adapters seam for OIDC (injected `authClient`) | One integration; module-mock in tests is acceptable | A second identity provider, or the mock cost turns real | -| Broad OpenAPI client generation | `features/tags` calls the five `/tags` endpoints; the rest of the generated surface is unused | The next business feature is wired | -| ESLint rule banning bare `fetch` outside `shared/api/` | Small surface, caught in review | The surface grows, or a bare `fetch` slips in | -| Shared handler for a session that goes invalid mid-visit (`Session.Missing` / `Authorization.Unauthorized` from a live API response) | Confirmed end to end: nothing reacts to this today, `ProtectedRoute` included — it only redirects off the OIDC snapshot, which an API 401 never touches (§5 has the audit and why it was removed from `useTagListPage` rather than left there) | A second page starts fetching past a session that can go stale mid-visit — build it once, in `shared/api`/`shared/session`, not per page | -| `identity-service` typed client | Consumed purely through the OIDC protocol | Never — it's protocol, not REST | -| External telemetry / observability backend | `shared/logger.ts` → `console` is enough | A real ops requirement appears | -| Pagination in `list-section` and a situation filter on the Pessoas list | `/clients` already pages and filters (ADR 0063) and Pessoas is the second list screen, but the owner defines the select and the paging controls separately; meanwhile the list shows only the first page and nothing says there are more (ADR 0067) | The owner defines the controls (follow-up of #155): `status` and `page` then enter `clientsRepository.list`, the URL and the hook together, with a `shared/ui` select and a `widgets/` pagination | -| `widgets/search-form`: the `role="search"` form and the URL-and-abort search logic of the list hooks | `TagListPage` and `ClientListPage` repeat about fifteen lines of markup and the same `?q=` / `AbortController` logic; two copies are too few to fix a shared shape | A third list screen (Serviços is the next candidate) needs the same search: extract the widget and a shared hook shape, then move Tags and Pessoas onto it | -| `list-section`'s `renderItem` (list, no header) mode exercised by a real screen | `TagListPage` uses the `columns` mode for its own three-field table | A listing without natural columns (e.g. a simple feed or timeline) needs it | -| `run()` classifying an aborted request's own `AbortError` as a distinct outcome, instead of folding it into `NETWORK_PROBLEM` | `servicesFacade.ts`'s `CallOptions`/`buildInit` accept an optional `signal` (added for `useTagListPage`'s `fetchTags`, ADR 0033's scoped exception, §5), but the one consumer that passes one checks `controller.signal.aborted` itself, before ever looking at the `ApiResult` — it never needs `.error.code` to say "cancelled" correctly | A second `signal`-aware caller needs to tell "I cancelled this" apart from "the network actually failed" via the `ApiResult` itself, not by holding its own `AbortController` reference the way `fetchTags` does | -| `widgets/repeatable-list`: one frame for an item of a dynamic list (`role="group"` named by an `h3`, plus a **Remover** button) | Two items repeat that header today, `ClientGuardianItem` and `ClientReferenceContactItem`, both on the same page, and editing (#156) reuses that page, so it is not a third. Two examples are too few to fix the shape of a shared widget, and the copy is about fifteen lines of markup | A third repeatable list appears in any slice: build `RepeatableItem({ title, removeLabel, onRemove, children })` and move the two client items onto it | +| Not built | Why not | Build it when | +| ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `entities/` and top-level `pages/` FSD layers | Nothing is shared across features yet | A second feature needs the same entity or page | +| Server-state library (TanStack Query, SWR) | `features/tags`'s one route proves a plain repository call — from a hook, a loader, or an action, whichever shape the screen needs that week — is enough so far | Cross-route caching, refetch-on-focus, or optimistic UI | +| Request-cancellation layer (`AbortController`) | The effect `ignore`-flag already fixes the race; nothing is slow enough to abort on the wire | Search-as-you-type, a large export | +| `toDomain(dto)` mappers | `tagsRepository` forwards `Tag`/`TagResponse` verbatim — no repository has needed one yet | A wire shape and a domain type genuinely diverge | +| Ports/adapters seam for OIDC (injected `authClient`) | One integration; module-mock in tests is acceptable | A second identity provider, or the mock cost turns real | +| Broad OpenAPI client generation | `features/tags` calls the five `/tags` endpoints; the rest of the generated surface is unused | The next business feature is wired | +| ESLint rule banning bare `fetch` outside `shared/api/` | Small surface, caught in review | The surface grows, or a bare `fetch` slips in | +| Shared handler for a session that goes invalid mid-visit (`Session.Missing` / `Authorization.Unauthorized` from a live API response) | Confirmed end to end: nothing reacts to this today, `ProtectedRoute` included — it only redirects off the OIDC snapshot, which an API 401 never touches (§5 has the audit and why it was removed from `useTagListPage` rather than left there) | A second page starts fetching past a session that can go stale mid-visit — build it once, in `shared/api`/`shared/session`, not per page | +| `identity-service` typed client | Consumed purely through the OIDC protocol | Never — it's protocol, not REST | +| External telemetry / observability backend | `shared/logger.ts` → `console` is enough | A real ops requirement appears | +| A situation filter on the Pessoas list | `/clients` already filters by `status` (ADR 0063), but the owner defines the filter control; until then the list shows every situation (ADR 0067, point 9) | The owner defines the control (follow-up of #155): `status` then enters `clientsRepository.list`, the URL and the hook together, on the `shared/ui/select` that the paging footer brought ([ADR 0068](../../../docs/adr/0068-admin-frontend-list-pagination.md)) | +| `widgets/search-form`: the `role="search"` form and the URL-and-abort search logic of the list hooks | `TagListPage` and `ClientListPage` repeat about fifteen lines of markup and the same `?q=` / `AbortController` logic; two copies are too few to fix a shared shape | A third list screen (Serviços is the next candidate) needs the same search: extract the widget and a shared hook shape, then move Tags and Pessoas onto it | +| `list-section`'s `renderItem` (list, no header) mode exercised by a real screen | `TagListPage` uses the `columns` mode for its own three-field table | A listing without natural columns (e.g. a simple feed or timeline) needs it | +| `run()` classifying an aborted request's own `AbortError` as a distinct outcome, instead of folding it into `NETWORK_PROBLEM` | `servicesFacade.ts`'s `CallOptions`/`buildInit` accept an optional `signal` (added for `useTagListPage`'s `fetchTags`, ADR 0033's scoped exception, §5), but the one consumer that passes one checks `controller.signal.aborted` itself, before ever looking at the `ApiResult` — it never needs `.error.code` to say "cancelled" correctly | A second `signal`-aware caller needs to tell "I cancelled this" apart from "the network actually failed" via the `ApiResult` itself, not by holding its own `AbortController` reference the way `fetchTags` does | +| `widgets/repeatable-list`: one frame for an item of a dynamic list (`role="group"` named by an `h3`, plus a **Remover** button) | Two items repeat that header today, `ClientGuardianItem` and `ClientReferenceContactItem`, both on the same page, and editing (#156) reuses that page, so it is not a third. Two examples are too few to fix the shape of a shared widget, and the copy is about fifteen lines of markup | A third repeatable list appears in any slice: build `RepeatableItem({ title, removeLabel, onRemove, children })` and move the two client items onto it | --- diff --git a/apps/admin-frontend/e2e/clients.spec.ts b/apps/admin-frontend/e2e/clients.spec.ts index cda19f18..b59add85 100644 --- a/apps/admin-frontend/e2e/clients.spec.ts +++ b/apps/admin-frontend/e2e/clients.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; -import { loginAsDemoUser } from './helpers'; +import { loginAsDemoUser, registerPeopleThroughApi } from './helpers'; function validCpf(): string { const checkDigit = (digits: number[]) => { @@ -198,3 +198,112 @@ test.describe('People list', () => { await expect(page.getByText('Em breve', { exact: true })).toBeVisible(); }); }); + +const PAGED_PEOPLE = 25; +let pagedPrefix: string | undefined; + +async function seedPagedPeople(page: Page): Promise { + if (pagedPrefix) return pagedPrefix; + + const prefix = `Paginacao E2E ${Date.now()}`; + const names = Array.from( + { length: PAGED_PEOPLE }, + (_, index) => `${prefix} ${String(index + 1).padStart(2, '0')}`, + ); + await registerPeopleThroughApi(page, names); + pagedPrefix = prefix; + return prefix; +} + +function pagingSummary(page: Page) { + return page.getByRole('status').filter({ hasText: 'Exibindo' }); +} + +function pageSizeSelect(page: Page) { + return page.getByRole('combobox', { name: 'Itens por página' }); +} + +test.describe('People list paging', () => { + let prefix: string; + + test.beforeEach(async ({ page }) => { + await loginAsDemoUser(page); + prefix = await seedPagedPeople(page); + await page.goto(`/pessoas?q=${encodeURIComponent(prefix)}`); + await expect(pagingSummary(page)).toHaveText(`Exibindo 1–20 de ${PAGED_PEOPLE} pessoas`); + }); + + test('moves between pages with Anterior and Próxima, keeping the search', async ({ page }) => { + await expect(page.getByRole('row')).toHaveCount(21); + + await page.getByRole('button', { name: 'Próxima' }).click(); + + await expect(pagingSummary(page)).toHaveText(`Exibindo 21–25 de ${PAGED_PEOPLE} pessoas`); + await expect(page.getByRole('row')).toHaveCount(6); + await expect(page).toHaveURL(/q=Paginacao\+E2E\+\d+&page=2$/); + await expect(page.getByRole('button', { name: 'Próxima' })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + + await page.getByRole('button', { name: 'Anterior' }).click(); + + await expect(pagingSummary(page)).toHaveText(`Exibindo 1–20 de ${PAGED_PEOPLE} pessoas`); + await expect(page).not.toHaveURL(/page=/); + }); + + test('goes straight to a numbered page', async ({ page }) => { + await page.getByRole('button', { name: 'Ir para a página 2' }).click(); + + await expect(pagingSummary(page)).toHaveText(`Exibindo 21–25 de ${PAGED_PEOPLE} pessoas`); + await expect(page.getByRole('button', { name: 'Página 2' })).toHaveAttribute( + 'aria-current', + 'page', + ); + }); + + test('changes the items per page and starts again from the first page', async ({ page }) => { + await page.goto(`/pessoas?q=${encodeURIComponent(prefix)}&page=2`); + await expect(pagingSummary(page)).toHaveText(`Exibindo 21–25 de ${PAGED_PEOPLE} pessoas`); + + await pageSizeSelect(page).click(); + await page.getByRole('option', { name: '10', exact: true }).click(); + + await expect(pagingSummary(page)).toHaveText(`Exibindo 1–10 de ${PAGED_PEOPLE} pessoas`); + await expect(page.getByRole('row')).toHaveCount(11); + await expect(page).toHaveURL(/pageSize=10$/); + await expect(page.getByRole('button', { name: 'Ir para a página 3' })).toBeVisible(); + }); + + test('keeps the page in the URL, so it survives a reload, and Back returns to the one before', async ({ + page, + }) => { + await page.getByRole('button', { name: 'Próxima' }).click(); + await expect(pagingSummary(page)).toHaveText(`Exibindo 21–25 de ${PAGED_PEOPLE} pessoas`); + + await page.reload(); + await expect(pagingSummary(page)).toHaveText(`Exibindo 21–25 de ${PAGED_PEOPLE} pessoas`); + + await page.goBack(); + await expect(pagingSummary(page)).toHaveText(`Exibindo 1–20 de ${PAGED_PEOPLE} pessoas`); + }); + + test('sends a page that does not exist to the last one that does', async ({ page }) => { + await page.goto(`/pessoas?q=${encodeURIComponent(prefix)}&page=99`); + + await expect(pagingSummary(page)).toHaveText(`Exibindo 21–25 de ${PAGED_PEOPLE} pessoas`); + await expect(page).toHaveURL(/page=2$/); + await expect(page.getByText('Nenhuma pessoa ainda')).toHaveCount(0); + }); + + test('starts over on the first page when the search changes', async ({ page }) => { + await page.getByRole('button', { name: 'Próxima' }).click(); + await expect(pagingSummary(page)).toHaveText(`Exibindo 21–25 de ${PAGED_PEOPLE} pessoas`); + + await searchFor(page, `${prefix} 07`); + + await expect(pagingSummary(page)).toHaveText('Exibindo 1 de 1 pessoa'); + await expect(page).not.toHaveURL(/page=/); + await expect(page.getByRole('row', { name: new RegExp(`${prefix} 07`) })).toBeVisible(); + }); +}); diff --git a/apps/admin-frontend/e2e/helpers.ts b/apps/admin-frontend/e2e/helpers.ts index 77415c18..8b530291 100644 --- a/apps/admin-frontend/e2e/helpers.ts +++ b/apps/admin-frontend/e2e/helpers.ts @@ -2,6 +2,8 @@ import type { Page } from '@playwright/test'; const DEMO_EMAIL = 'owner@demo.local'; const DEMO_PASSWORD = 'Passw0rd!'; +const OIDC_STORAGE_KEY = 'oidc.user:http://localhost:5081:admin-panel'; +const CLIENTS_URL = 'http://localhost:5080/api/v1.0/clients'; export const LOGIN_URL_RE = /localhost:5081\/Account\/Login/; export async function loginAsDemoUser(page: Page): Promise { @@ -12,3 +14,41 @@ export async function loginAsDemoUser(page: Page): Promise { await page.locator('button[type="submit"]').click(); await page.waitForURL('http://localhost:5173/'); } + +export async function registerPeopleThroughApi(page: Page, fullNames: string[]): Promise { + await page.evaluate( + async ({ storageKey, url, names }) => { + const stored = JSON.parse(localStorage.getItem(storageKey) ?? '{}') as { + access_token: string; + }; + const payloadSegment = stored.access_token.split('.')[1] ?? ''; + const payload = JSON.parse(atob(payloadSegment.replace(/-/g, '+').replace(/_/g, '/'))) as { + tenant_id: string; + }; + + for (const fullName of names) { + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${stored.access_token}`, + 'X-Tenant-Id': payload.tenant_id, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fullName, + birthDate: null, + phone: null, + email: null, + cpf: null, + administrativeNotes: null, + guardians: [], + referenceContacts: [], + }), + }); + if (!response.ok) + throw new Error(`POST /clients answered ${response.status} for ${fullName}`); + } + }, + { storageKey: OIDC_STORAGE_KEY, url: CLIENTS_URL, names: fullNames }, + ); +} diff --git a/apps/admin-frontend/src/features/clients/api/clientsRepository.test.ts b/apps/admin-frontend/src/features/clients/api/clientsRepository.test.ts index d46a714a..ae4f711c 100644 --- a/apps/admin-frontend/src/features/clients/api/clientsRepository.test.ts +++ b/apps/admin-frontend/src/features/clients/api/clientsRepository.test.ts @@ -20,33 +20,53 @@ const LIST_ITEM = { }; describe('clientsRepository.list', () => { - it('sends the search term as Search and nothing else, leaving the backend defaults (everyone, first page) in charge', async () => { + const FIRST_PAGE_OF_EVERYONE = { search: '', page: 1, pageSize: 20 }; + + it('always asks for a page and a size, so the screen never depends on the backend defaults', async () => { mockGet.mockResolvedValue({ ok: true, data: { items: [], totalCount: 0 } }); - await clientsRepository.list('maria'); + await clientsRepository.list(FIRST_PAGE_OF_EVERYONE); expect(mockGet).toHaveBeenCalledWith('/api/v{version}/clients', { - query: { Search: 'maria' }, + query: { Page: 1, PageSize: 20 }, signal: undefined, }); }); - it('sends no query at all without a search term', async () => { + it('sends the page, the size and the search term under the names the API reads', async () => { mockGet.mockResolvedValue({ ok: true, data: { items: [], totalCount: 0 } }); - await clientsRepository.list(); + await clientsRepository.list({ search: 'maria', page: 3, pageSize: 50 }); expect(mockGet).toHaveBeenCalledWith('/api/v{version}/clients', { - query: undefined, + query: { Page: 3, PageSize: 50, Search: 'maria' }, signal: undefined, }); }); + it('leaves Search out, instead of sending it empty, without a search term', async () => { + mockGet.mockResolvedValue({ ok: true, data: { items: [], totalCount: 0 } }); + + await clientsRepository.list(FIRST_PAGE_OF_EVERYONE); + + const [, options] = mockGet.mock.calls.at(-1) as [string, { query: object }]; + expect(options.query).not.toHaveProperty('Search'); + }); + + it('never sends a status, which lists every situation', async () => { + mockGet.mockResolvedValue({ ok: true, data: { items: [], totalCount: 0 } }); + + await clientsRepository.list({ search: 'maria', page: 2, pageSize: 10 }); + + const [, options] = mockGet.mock.calls.at(-1) as [string, { query: object }]; + expect(options.query).not.toHaveProperty('Status'); + }); + it('hands the abort signal over to the request', async () => { mockGet.mockResolvedValue({ ok: true, data: { items: [], totalCount: 0 } }); const controller = new AbortController(); - await clientsRepository.list('maria', controller.signal); + await clientsRepository.list({ search: 'maria', page: 1, pageSize: 20 }, controller.signal); const [, options] = mockGet.mock.calls.at(-1) as [string, { signal: AbortSignal }]; expect(options.signal).toBe(controller.signal); @@ -56,7 +76,7 @@ describe('clientsRepository.list', () => { const page = { items: [LIST_ITEM], totalCount: 21, page: 1, pageSize: 20 }; mockGet.mockResolvedValue({ ok: true, data: page }); - const result = await clientsRepository.list(); + const result = await clientsRepository.list(FIRST_PAGE_OF_EVERYONE); expect(result).toEqual({ ok: true, data: page }); }); @@ -69,7 +89,7 @@ describe('clientsRepository.list', () => { }; mockGet.mockResolvedValue({ ok: false, error }); - const result = await clientsRepository.list('x'); + const result = await clientsRepository.list({ search: 'x', page: 1, pageSize: 20 }); expect(result).toEqual({ ok: false, error }); }); diff --git a/apps/admin-frontend/src/features/clients/api/clientsRepository.ts b/apps/admin-frontend/src/features/clients/api/clientsRepository.ts index 2e8d2a26..444e5e0c 100644 --- a/apps/admin-frontend/src/features/clients/api/clientsRepository.ts +++ b/apps/admin-frontend/src/features/clients/api/clientsRepository.ts @@ -1,11 +1,19 @@ import { servicesApi } from '@/shared/api/servicesApi'; import type { ApiResult } from '@/shared/api/servicesFacade'; +import type { Paging } from '@/shared/pagination/paging'; import type { Client, ClientInput } from '../model/client'; import type { ClientList } from '../model/clientList'; -function list(search?: string, signal?: AbortSignal): Promise> { +export interface ClientListQuery extends Paging { + search: string; +} + +function list( + { search, page, pageSize }: ClientListQuery, + signal?: AbortSignal, +): Promise> { return servicesApi.get('/api/v{version}/clients', { - query: search ? { Search: search } : undefined, + query: { Page: page, PageSize: pageSize, ...(search ? { Search: search } : {}) }, signal, }); } diff --git a/apps/admin-frontend/src/features/clients/model/clientList.ts b/apps/admin-frontend/src/features/clients/model/clientList.ts index dcbf47db..40e29068 100644 --- a/apps/admin-frontend/src/features/clients/model/clientList.ts +++ b/apps/admin-frontend/src/features/clients/model/clientList.ts @@ -1,3 +1,4 @@ +import type { PagedResult } from '@/shared/pagination/paging'; import type { ClientStatus } from './clientStatus'; export interface ClientListItem { @@ -9,9 +10,4 @@ export interface ClientListItem { status: ClientStatus; } -export interface ClientList { - items: ClientListItem[]; - totalCount: number; - page: number; - pageSize: number; -} +export type ClientList = PagedResult; diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.test.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.test.tsx index 67fe896a..290c5c84 100644 --- a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.test.tsx +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.test.tsx @@ -3,6 +3,7 @@ import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createMemoryRouter, MemoryRouter, RouterProvider, useLocation } from 'react-router'; import { ClientListPage } from './ClientListPage'; +import type { ClientListQuery } from '../../../api/clientsRepository'; import type { ClientList, ClientListItem } from '../../../model/clientList'; const { mockList } = vi.hoisted(() => ({ mockList: vi.fn() })); @@ -12,6 +13,7 @@ vi.mock('../../../api/clientsRepository', () => ({ })); const SEARCH_LABEL = 'Buscar pessoas por nome, e-mail ou CPF'; +const PAGE_SIZE_LABEL = 'Itens por página'; const ANA: ClientListItem = { id: 'c1', @@ -33,8 +35,44 @@ const BRUNO: ClientListItem = { const CLIENTS = [ANA, BRUNO]; -function listOf(items: ClientListItem[]): { ok: true; data: ClientList } { - return { ok: true, data: { items, totalCount: items.length, page: 1, pageSize: 20 } }; +const FIRST_PAGE_OF_EVERYONE = { search: '', page: 1, pageSize: 20 }; + +function listOf( + items: ClientListItem[], + paging: Partial = {}, +): { ok: true; data: ClientList } { + return { + ok: true, + data: { items, totalCount: items.length, page: 1, pageSize: 20, ...paging }, + }; +} + +function people(count: number): ClientListItem[] { + return Array.from({ length: count }, (_, index) => ({ + id: `p${index + 1}`, + fullName: `Pessoa ${String(index + 1).padStart(3, '0')}`, + phone: null, + email: null, + cpf: null, + status: 'active', + })); +} + +function serve(everyone: ClientListItem[]) { + mockList.mockImplementation(({ search, page, pageSize }: ClientListQuery) => { + const needle = search.toLowerCase(); + const matching = needle + ? everyone.filter((client) => client.fullName.toLowerCase().includes(needle)) + : everyone; + const start = (page - 1) * pageSize; + return Promise.resolve( + listOf(matching.slice(start, start + pageSize), { + totalCount: matching.length, + page, + pageSize, + }), + ); + }); } function LocationProbe() { @@ -52,47 +90,48 @@ function renderClientListPage(initialEntries: string[] = ['/pessoas']) { } function renderPage(clients: ClientListItem[], initialEntries?: string[]) { - mockList.mockImplementation((search?: string) => { - const needle = search?.toLowerCase(); - const filtered = needle - ? clients.filter((client) => client.fullName.toLowerCase().includes(needle)) - : clients; - return Promise.resolve(listOf(filtered)); - }); + serve(clients); return renderClientListPage(initialEntries); } +function pending() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function location() { + return screen.getByTestId('location').textContent; +} + describe('ClientListPage', () => { beforeEach(() => { vi.clearAllMocks(); }); it('shows the table loading skeleton on first render, before the initial fetch resolves', async () => { - let resolveInitial!: (value: ReturnType) => void; - mockList.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveInitial = resolve; - }), - ); + const initial = pending>(); + mockList.mockImplementationOnce(() => initial.promise); const { container } = renderClientListPage(); expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); expect(screen.queryByText('Nenhuma pessoa ainda')).not.toBeInTheDocument(); - resolveInitial(listOf(CLIENTS)); + initial.resolve(listOf(CLIENTS)); await waitFor(() => expect(screen.getByText('Ana Souza')).toBeInTheDocument()); expect(container.querySelector('[aria-busy="true"]')).toBeNull(); }); - it('asks for the first page of active people with no search on the first load', async () => { + it('asks for the first page of 20 people, with no search, on the first load', async () => { renderPage(CLIENTS); await screen.findByText('Ana Souza'); expect(mockList).toHaveBeenCalledTimes(1); - expect(mockList).toHaveBeenCalledWith(undefined, expect.any(AbortSignal)); + expect(mockList).toHaveBeenCalledWith(FIRST_PAGE_OF_EVERYONE, expect.any(AbortSignal)); }); it('renders the Pessoas title and a "Nova pessoa" link pointing at the registration form', async () => { @@ -197,26 +236,40 @@ describe('ClientListPage', () => { ok: false, error: { title: 'Sem conexão com o servidor. Tente novamente.' }, }); - let resolveRetry!: (value: ReturnType) => void; - mockList.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveRetry = resolve; - }), - ); + const retry = pending>(); + mockList.mockImplementationOnce(() => retry.promise); const { container } = renderClientListPage(['/pessoas?q=ana']); await user.click(await screen.findByRole('button', { name: 'Tentar novamente' })); expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); - expect(mockList).toHaveBeenLastCalledWith('ana', expect.any(AbortSignal)); + expect(mockList).toHaveBeenLastCalledWith( + { search: 'ana', page: 1, pageSize: 20 }, + expect.any(AbortSignal), + ); - resolveRetry(listOf([ANA])); + retry.resolve(listOf([ANA])); expect(await screen.findByText('Ana Souza')).toBeInTheDocument(); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); + it('retries the same page and size from the failure, not the first page', async () => { + const user = userEvent.setup(); + mockList.mockResolvedValueOnce({ ok: false, error: { title: 'Sem conexão.' } }); + mockList.mockResolvedValueOnce(listOf(people(10), { totalCount: 87, page: 3, pageSize: 10 })); + renderClientListPage(['/pessoas?page=3&pageSize=10']); + + await user.click(await screen.findByRole('button', { name: 'Tentar novamente' })); + + await screen.findByText('Pessoa 001'); + expect(mockList).toHaveBeenLastCalledWith( + { search: '', page: 3, pageSize: 10 }, + expect.any(AbortSignal), + ); + expect(location()).toBe('location: /pessoas?page=3&pageSize=10'); + }); + it('says there is nobody yet, without a search action, when the list is empty and nothing was searched', async () => { renderPage([]); @@ -243,7 +296,7 @@ describe('ClientListPage', () => { await user.click(await screen.findByRole('button', { name: 'Limpar busca' })); expect(await screen.findByText('Ana Souza')).toBeInTheDocument(); - expect(screen.getByTestId('location').textContent).toBe('location: /pessoas'); + expect(location()).toBe('location: /pessoas'); expect(screen.getByLabelText(SEARCH_LABEL)).toHaveValue(''); }); @@ -267,8 +320,11 @@ describe('ClientListPage', () => { await waitFor(() => expect(screen.queryByText('Ana Souza')).not.toBeInTheDocument()); expect(screen.getByText('Bruno Lima')).toBeInTheDocument(); - expect(mockList).toHaveBeenLastCalledWith('bruno', expect.any(AbortSignal)); - expect(screen.getByTestId('location').textContent).toBe('location: /pessoas?q=bruno'); + expect(mockList).toHaveBeenLastCalledWith( + { search: 'bruno', page: 1, pageSize: 20 }, + expect.any(AbortSignal), + ); + expect(location()).toBe('location: /pessoas?q=bruno'); }); it('searches from the Buscar button too', async () => { @@ -287,7 +343,12 @@ describe('ClientListPage', () => { renderPage(CLIENTS, ['/pessoas?q=bruno']); expect(screen.getByLabelText(SEARCH_LABEL)).toHaveValue('bruno'); - await waitFor(() => expect(mockList).toHaveBeenCalledWith('bruno', expect.any(AbortSignal))); + await waitFor(() => + expect(mockList).toHaveBeenCalledWith( + { search: 'bruno', page: 1, pageSize: 20 }, + expect.any(AbortSignal), + ), + ); expect(await screen.findByText('Bruno Lima')).toBeInTheDocument(); expect(screen.queryByText('Ana Souza')).not.toBeInTheDocument(); }); @@ -300,9 +361,7 @@ describe('ClientListPage', () => { await user.clear(screen.getByLabelText(SEARCH_LABEL)); await user.keyboard('{Enter}'); - await waitFor(() => - expect(screen.getByTestId('location').textContent).toBe('location: /pessoas'), - ); + await waitFor(() => expect(location()).toBe('location: /pessoas')); }); it('shows the loading skeleton while a new search is in flight, replacing the stale rows', async () => { @@ -311,13 +370,8 @@ describe('ClientListPage', () => { const { container } = renderClientListPage(); await screen.findByText('Ana Souza'); - let resolveSearch!: (value: ReturnType) => void; - mockList.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveSearch = resolve; - }), - ); + const search = pending>(); + mockList.mockImplementationOnce(() => search.promise); await user.type(screen.getByLabelText(SEARCH_LABEL), 'bruno{Enter}'); @@ -325,7 +379,7 @@ describe('ClientListPage', () => { expect(screen.queryByText('Ana Souza')).not.toBeInTheDocument(); expect(screen.queryByText('Nenhuma pessoa encontrada para «bruno»')).not.toBeInTheDocument(); - resolveSearch(listOf([BRUNO])); + search.resolve(listOf([BRUNO])); await waitFor(() => expect(screen.getByText('Bruno Lima')).toBeInTheDocument()); expect(container.querySelector('[aria-busy="true"]')).toBeNull(); @@ -336,36 +390,26 @@ describe('ClientListPage', () => { renderPage(CLIENTS); await screen.findByText('Ana Souza'); - let resolveFirst!: (value: ReturnType) => void; - mockList.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirst = resolve; - }), - ); + const first = pending>(); + mockList.mockImplementationOnce(() => first.promise); const searchInput = screen.getByLabelText(SEARCH_LABEL); await user.type(searchInput, 'bruno{Enter}'); await waitFor(() => expect(mockList).toHaveBeenCalledTimes(2)); - let resolveSecond!: (value: ReturnType) => void; - mockList.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveSecond = resolve; - }), - ); + const second = pending>(); + mockList.mockImplementationOnce(() => second.promise); await user.clear(searchInput); await user.type(searchInput, 'ana{Enter}'); await waitFor(() => expect(mockList).toHaveBeenCalledTimes(3)); await act(async () => { - resolveSecond(listOf([ANA])); + second.resolve(listOf([ANA])); await Promise.resolve(); }); expect(screen.getByText('Ana Souza')).toBeInTheDocument(); await act(async () => { - resolveFirst(listOf([BRUNO])); + first.resolve(listOf([BRUNO])); await Promise.resolve(); }); expect(screen.getByText('Ana Souza')).toBeInTheDocument(); @@ -386,7 +430,7 @@ describe('ClientListPage', () => { it('shows the search term of the current URL again when Back and Forward change ?q=', async () => { const user = userEvent.setup(); - mockList.mockImplementation((search?: string) => + mockList.mockImplementation(({ search }: ClientListQuery) => Promise.resolve( listOf( search @@ -415,4 +459,290 @@ describe('ClientListPage', () => { await waitFor(() => expect(screen.queryByText('Ana Souza')).not.toBeInTheDocument()); expect(searchInput).toHaveValue('bruno'); }); + + describe('paging', () => { + it('asks for the page and the size in the URL', async () => { + renderPage(people(87), ['/pessoas?page=3&pageSize=10']); + + expect(await screen.findByText('Pessoa 021')).toBeInTheDocument(); + expect(mockList).toHaveBeenCalledTimes(1); + expect(mockList).toHaveBeenCalledWith( + { search: '', page: 3, pageSize: 10 }, + expect.any(AbortSignal), + ); + }); + + it('says which people are on screen out of how many, below the table', async () => { + renderPage(people(87)); + + const status = await screen.findByRole('status'); + expect(status).toHaveTextContent('Exibindo 1–20 de 87 pessoas'); + expect(screen.getByText('Pessoa 020')).toBeInTheDocument(); + expect(screen.queryByText('Pessoa 021')).not.toBeInTheDocument(); + const table = screen.getByRole('table', { name: 'Pessoas' }); + expect(table.compareDocumentPosition(status)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + }); + + it('has no controls before the first answer, when the total is still unknown', async () => { + const initial = pending>(); + mockList.mockImplementationOnce(() => initial.promise); + renderClientListPage(); + + expect(screen.queryByRole('navigation', { name: 'Paginação' })).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + + initial.resolve(listOf(people(87).slice(0, 20), { totalCount: 87 })); + + expect(await screen.findByRole('navigation', { name: 'Paginação' })).toBeInTheDocument(); + }); + + it('goes to the next page, keeping the search and the size, and puts the page in the URL', async () => { + const user = userEvent.setup(); + renderPage(people(87), ['/pessoas?q=pessoa&pageSize=10']); + await screen.findByText('Pessoa 001'); + + await user.click(screen.getByRole('button', { name: 'Próxima' })); + + expect(await screen.findByText('Pessoa 011')).toBeInTheDocument(); + expect(screen.queryByText('Pessoa 001')).not.toBeInTheDocument(); + expect(mockList).toHaveBeenLastCalledWith( + { search: 'pessoa', page: 2, pageSize: 10 }, + expect.any(AbortSignal), + ); + expect(location()).toBe('location: /pessoas?q=pessoa&pageSize=10&page=2'); + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 11–20 de 87 pessoas'); + }); + + it('goes to the page of a number', async () => { + const user = userEvent.setup(); + renderPage(people(87)); + await screen.findByText('Pessoa 001'); + + await user.click(screen.getByRole('button', { name: 'Ir para a página 3' })); + + expect(await screen.findByText('Pessoa 041')).toBeInTheDocument(); + expect(location()).toBe('location: /pessoas?page=3'); + }); + + it('leaves the URL without ?page= when it goes back to the first page', async () => { + const user = userEvent.setup(); + renderPage(people(87), ['/pessoas?page=2']); + await screen.findByText('Pessoa 021'); + + await user.click(screen.getByRole('button', { name: 'Anterior' })); + + expect(await screen.findByText('Pessoa 001')).toBeInTheDocument(); + expect(location()).toBe('location: /pessoas'); + }); + + it('keeps the controls over the skeleton, disabled and on the last known total, while the next page loads', async () => { + const user = userEvent.setup(); + const everyone = people(87); + mockList.mockResolvedValueOnce(listOf(everyone.slice(0, 20), { totalCount: 87 })); + const { container } = renderClientListPage(); + await screen.findByText('Pessoa 001'); + + const next = pending>(); + mockList.mockImplementationOnce(() => next.promise); + await user.click(screen.getByRole('button', { name: 'Próxima' })); + + await waitFor(() => expect(container.querySelector('[aria-busy="true"]')).not.toBeNull()); + expect(screen.queryByText('Pessoa 001')).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 21–40 de 87 pessoas'); + expect(screen.getByRole('button', { name: 'Próxima' })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + expect(screen.getByRole('combobox', { name: PAGE_SIZE_LABEL })).toBeDisabled(); + + next.resolve(listOf(everyone.slice(20, 40), { totalCount: 87, page: 2 })); + + expect(await screen.findByText('Pessoa 021')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Próxima' })).toHaveAttribute( + 'aria-disabled', + 'false', + ); + expect(screen.getByRole('combobox', { name: PAGE_SIZE_LABEL })).toBeEnabled(); + }); + + it('goes back to the first page when the size changes, keeping the search', async () => { + const user = userEvent.setup(); + renderPage(people(87), ['/pessoas?q=pessoa&page=3']); + await screen.findByText('Pessoa 041'); + + await user.click(screen.getByRole('combobox', { name: PAGE_SIZE_LABEL })); + await user.click(await screen.findByRole('option', { name: '50' })); + + expect(await screen.findByText('Pessoa 050')).toBeInTheDocument(); + expect(mockList).toHaveBeenLastCalledWith( + { search: 'pessoa', page: 1, pageSize: 50 }, + expect.any(AbortSignal), + ); + expect(location()).toBe('location: /pessoas?q=pessoa&pageSize=50'); + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 1–50 de 87 pessoas'); + }); + + it('goes back to the default size without leaving ?pageSize= behind', async () => { + const user = userEvent.setup(); + renderPage(people(87), ['/pessoas?pageSize=50']); + await screen.findByText('Pessoa 050'); + + await user.click(screen.getByRole('combobox', { name: PAGE_SIZE_LABEL })); + await user.click(await screen.findByRole('option', { name: '20' })); + + await screen.findByText('Pessoa 020'); + expect(location()).toBe('location: /pessoas'); + }); + + it('starts over on the first page when a search is submitted, keeping the size', async () => { + const user = userEvent.setup(); + renderPage(people(87), ['/pessoas?page=3&pageSize=10']); + await screen.findByText('Pessoa 021'); + + await user.type(screen.getByLabelText(SEARCH_LABEL), 'pessoa 0{Enter}'); + + expect(await screen.findByText('Pessoa 001')).toBeInTheDocument(); + expect(mockList).toHaveBeenLastCalledWith( + { search: 'pessoa 0', page: 1, pageSize: 10 }, + expect.any(AbortSignal), + ); + expect(location()).toBe('location: /pessoas?pageSize=10&q=pessoa+0'); + }); + + it('starts over on the first page when the search is cleared, keeping the size', async () => { + const user = userEvent.setup(); + renderPage(people(87), ['/pessoas?q=zzz&page=2&pageSize=50']); + await user.click(await screen.findByRole('button', { name: 'Limpar busca' })); + + expect(await screen.findByText('Pessoa 001')).toBeInTheDocument(); + expect(location()).toBe('location: /pessoas?pageSize=50'); + }); + + it('has no controls when a search finds nobody', async () => { + renderPage(people(87), ['/pessoas?q=zzz']); + + await screen.findByText('Nenhuma pessoa encontrada para «zzz»'); + expect(screen.queryByRole('navigation', { name: 'Paginação' })).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('has no controls when the list failed to load', async () => { + mockList.mockResolvedValue({ ok: false, error: { title: 'Sem conexão.' } }); + renderClientListPage(); + + await screen.findByRole('alert'); + expect(screen.queryByRole('navigation', { name: 'Paginação' })).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('follows Back and Forward through the pages', async () => { + const user = userEvent.setup(); + serve(people(87)); + const router = createMemoryRouter([{ path: '/pessoas', element: }], { + initialEntries: ['/pessoas'], + }); + render(); + await screen.findByText('Pessoa 001'); + + await user.click(screen.getByRole('button', { name: 'Próxima' })); + await screen.findByText('Pessoa 021'); + + await act(() => router.navigate(-1)); + expect(await screen.findByText('Pessoa 001')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 1–20 de 87 pessoas'); + + await act(() => router.navigate(1)); + expect(await screen.findByText('Pessoa 021')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 21–40 de 87 pessoas'); + }); + + describe('a page or size that cannot be shown', () => { + it('rewrites an unreadable page and size out of the URL, keeping the rest, without asking twice', async () => { + renderPage(people(87), ['/pessoas?q=pessoa&page=abc&pageSize=33']); + + expect(await screen.findByText('Pessoa 001')).toBeInTheDocument(); + await waitFor(() => expect(location()).toBe('location: /pessoas?q=pessoa')); + expect(mockList).toHaveBeenCalledTimes(1); + expect(mockList).toHaveBeenCalledWith( + { search: 'pessoa', page: 1, pageSize: 20 }, + expect.any(AbortSignal), + ); + }); + + it.each(['0', '-1', '1.5', '99999999999999999999'])( + 'treats ?page=%s as the first page', + async (page) => { + renderPage(people(87), [`/pessoas?page=${page}`]); + + expect(await screen.findByText('Pessoa 001')).toBeInTheDocument(); + expect(mockList).toHaveBeenCalledWith(FIRST_PAGE_OF_EVERYONE, expect.any(AbortSignal)); + await waitFor(() => expect(location()).toBe('location: /pessoas')); + }, + ); + + it('sends the person to the last page that exists when the one asked for is past it', async () => { + const everyone = people(87); + renderPage(everyone, ['/pessoas?page=99']); + + expect(await screen.findByText('Pessoa 081')).toBeInTheDocument(); + expect(screen.getByText('Pessoa 087')).toBeInTheDocument(); + expect(location()).toBe('location: /pessoas?page=5'); + expect(mockList).toHaveBeenNthCalledWith( + 1, + { search: '', page: 99, pageSize: 20 }, + expect.any(AbortSignal), + ); + expect(mockList).toHaveBeenNthCalledWith( + 2, + { search: '', page: 5, pageSize: 20 }, + expect.any(AbortSignal), + ); + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 81–87 de 87 pessoas'); + }); + + it('shows the skeleton, not an empty list, while it moves to the last page that exists', async () => { + const everyone = people(87); + const lastPage = pending>(); + mockList.mockImplementationOnce(() => + Promise.resolve(listOf([], { totalCount: 87, page: 99 })), + ); + mockList.mockImplementationOnce(() => lastPage.promise); + const { container } = renderClientListPage(['/pessoas?page=99']); + + await waitFor(() => expect(mockList).toHaveBeenCalledTimes(2)); + expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + expect(screen.queryByText('Nenhuma pessoa ainda')).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 81–87 de 87 pessoas'); + expect(screen.getByRole('combobox', { name: PAGE_SIZE_LABEL })).toBeDisabled(); + + lastPage.resolve(listOf(everyone.slice(80), { totalCount: 87, page: 5 })); + + expect(await screen.findByText('Pessoa 081')).toBeInTheDocument(); + }); + + it('replaces the address, so Back does not return to the page that did not exist', async () => { + serve(people(87)); + const router = createMemoryRouter( + [ + { path: '/inicio', element:
Início
}, + { path: '/pessoas', element: }, + ], + { initialEntries: ['/inicio', '/pessoas?page=99'], initialIndex: 1 }, + ); + render(); + await screen.findByText('Pessoa 081'); + + await act(() => router.navigate(-1)); + + expect(router.state.location.pathname).toBe('/inicio'); + }); + + it('sends a page past the first to the first when nothing was found at all', async () => { + renderPage(people(87), ['/pessoas?q=zzz&page=3']); + + expect(await screen.findByText('Nenhuma pessoa encontrada para «zzz»')).toBeInTheDocument(); + await waitFor(() => expect(location()).toBe('location: /pessoas?q=zzz')); + }); + }); + }); }); diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.tsx index ae13fd3b..6ed14483 100644 --- a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.tsx +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.tsx @@ -2,6 +2,7 @@ import { PlusIcon } from 'lucide-react'; import { Link } from 'react-router'; import { buttonVariants } from '@/shared/ui/button'; import { ListSection, type ListSectionEmptyCopy } from '@/widgets/list-section'; +import type { PaginationItemLabel } from '@/widgets/pagination'; import { NEW_CLIENT_PATH } from '../../clientsPaths'; import { clientColumns } from './components/clientColumns'; import { ClientListSearch } from './components/ClientListSearch'; @@ -14,16 +15,21 @@ const EMPTY_COPY: ListSectionEmptyCopy = { searchDescription: 'Confira a grafia ou busque pelo e-mail ou pelo CPF.', }; +const ITEM_LABEL: PaginationItemLabel = { singular: 'pessoa', plural: 'pessoas' }; + export function ClientListPage() { const { clients, status, failure, query, + paging, searchInputRef, onSearchSubmit, onClearSearch, onRetry, + onPageChange, + onPageSizeChange, } = useClientListPage(); return ( @@ -52,6 +58,7 @@ export function ClientListPage() { search={{ term: query, onClear: onClearSearch }} errorTitle="Não foi possível carregar as pessoas." failure={failure && { ...failure, onRetry }} + pagination={paging && { ...paging, itemLabel: ITEM_LABEL, onPageChange, onPageSizeChange }} /> ); diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.ts b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.ts index e99d6742..cf150d2f 100644 --- a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.ts +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.ts @@ -1,8 +1,9 @@ import { useCallback, useEffect, useRef, useState, type SubmitEvent } from 'react'; import { useSearchParams } from 'react-router'; import { extractErrorMessage, type ApiResult } from '@/shared/api/servicesFacade'; +import { FIRST_PAGE, getLastPage, readPaging, withPaging } from '@/shared/pagination/paging'; import { ListSectionStatus } from '@/widgets/list-section'; -import { clientsRepository } from '../../../api/clientsRepository'; +import { clientsRepository, type ClientListQuery } from '../../../api/clientsRepository'; import type { ClientList } from '../../../model/clientList'; import type { ClientListState, @@ -10,49 +11,86 @@ import type { UseClientListPageResult, } from './useClientListPage.types'; +function isSameRequest(left: ClientListQuery, right: ClientListQuery): boolean { + return ( + left.search === right.search && left.page === right.page && left.pageSize === right.pageSize + ); +} + export function useClientListPage(): UseClientListPageResult { const [searchParams, setSearchParams] = useSearchParams(); const query = searchParams.get('q') ?? ''; + const { page, pageSize } = readPaging(searchParams); const [loaded, setLoaded] = useState(null); + const [totalCount, setTotalCount] = useState(null); const searchInputRef = useRef(null); const abortControllerRef = useRef(null); - const fetchClients = useCallback((forQuery: string) => { + const fetchClients = useCallback((request: ClientListQuery) => { abortControllerRef.current?.abort(); const controller = new AbortController(); abortControllerRef.current = controller; - void clientsRepository.list(forQuery || undefined, controller.signal).then((apiResult) => { + void clientsRepository.list(request, controller.signal).then((apiResult) => { if (controller.signal.aborted) return; - setLoaded({ query: forQuery, apiResult }); + if (apiResult.ok) setTotalCount(apiResult.data.totalCount); + setLoaded({ request, apiResult }); }); }, []); useEffect(() => { - fetchClients(query); + fetchClients({ search: query, page, pageSize }); return () => { abortControllerRef.current?.abort(); }; - }, [query, fetchClients]); + }, [query, page, pageSize, fetchClients]); useEffect(() => { const input = searchInputRef.current; if (input && input.value !== query) input.value = query; }, [query]); + const request: ClientListQuery = { search: query, page, pageSize }; + const current = loaded && isSameRequest(loaded.request, request) ? loaded.apiResult : null; + const lastPage = current?.ok ? getLastPage(current.data.totalCount, pageSize) : null; + const pastLastPage = lastPage !== null && page > lastPage; + const canonicalSearch = withPaging(searchParams, { + page: pastLastPage ? lastPage : page, + pageSize, + }).toString(); + + useEffect(() => { + if (canonicalSearch === searchParams.toString()) return; + setSearchParams(new URLSearchParams(canonicalSearch), { replace: true }); + }, [canonicalSearch, searchParams, setSearchParams]); + + function onSearchChange(nextSearch: string) { + const next = new URLSearchParams(searchParams); + if (nextSearch) next.set('q', nextSearch); + else next.delete('q'); + setSearchParams(withPaging(next, { page: FIRST_PAGE, pageSize })); + } + function onSearchSubmit(event: SubmitEvent) { event.preventDefault(); - const nextQuery = searchInputRef.current?.value ?? ''; - setSearchParams(nextQuery ? { q: nextQuery } : {}); + onSearchChange(searchInputRef.current?.value ?? ''); } function onClearSearch() { - setSearchParams({}); + onSearchChange(''); + } + + function onPageChange(nextPage: number) { + setSearchParams(withPaging(searchParams, { page: nextPage, pageSize })); + } + + function onPageSizeChange(nextPageSize: number) { + setSearchParams(withPaging(searchParams, { page: FIRST_PAGE, pageSize: nextPageSize })); } function onRetry() { setLoaded(null); - fetchClients(query); + fetchClients(request); } function toListState(apiResult: ApiResult | null): ClientListState { @@ -72,14 +110,15 @@ export function useClientListPage(): UseClientListPageResult { return { status: ListSectionStatus.Ready, clients: apiResult.data.items, failure: null }; } - const current = loaded?.query === query ? loaded.apiResult : null; - return { - ...toListState(current), + ...toListState(pastLastPage ? null : current), query, + paging: totalCount === null || pastLastPage ? null : { page, pageSize, totalCount }, searchInputRef, onSearchSubmit, onClearSearch, onRetry, + onPageChange, + onPageSizeChange, }; } diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.types.ts b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.types.ts index 17cae9fd..f1803545 100644 --- a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.types.ts +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.types.ts @@ -1,6 +1,7 @@ import type { RefObject, SubmitEvent } from 'react'; import type { ApiResult } from '@/shared/api/servicesFacade'; -import type { ListSectionStatus } from '@/widgets/list-section'; +import type { ListSectionPagination, ListSectionStatus } from '@/widgets/list-section'; +import type { ClientListQuery } from '../../../api/clientsRepository'; import type { ClientList, ClientListItem } from '../../../model/clientList'; export interface ClientListFailure { @@ -14,15 +15,20 @@ export interface ClientListState { failure: ClientListFailure | null; } +export type ClientListPaging = Pick; + export interface UseClientListPageResult extends ClientListState { query: string; + paging: ClientListPaging | null; searchInputRef: RefObject; onSearchSubmit: (event: SubmitEvent) => void; onClearSearch: () => void; onRetry: () => void; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; } export interface LoadResult { - query: string; + request: ClientListQuery; apiResult: ApiResult; } diff --git a/apps/admin-frontend/src/shared/pagination/paging.test.ts b/apps/admin-frontend/src/shared/pagination/paging.test.ts new file mode 100644 index 00000000..a43007ee --- /dev/null +++ b/apps/admin-frontend/src/shared/pagination/paging.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_PAGE_SIZE, + PAGE_SIZE_OPTIONS, + getLastPage, + getPageWindow, + getVisibleRange, + readPaging, + withPaging, +} from './paging'; + +describe('readPaging', () => { + it('starts on the first page with the default size when the URL says nothing', () => { + expect(readPaging(new URLSearchParams())).toEqual({ page: 1, pageSize: DEFAULT_PAGE_SIZE }); + }); + + it('reads the page and the size from ?page= and ?pageSize=', () => { + expect(readPaging(new URLSearchParams('page=3&pageSize=50'))).toEqual({ + page: 3, + pageSize: 50, + }); + }); + + it.each(['abc', '0', '-2', '1.5', '', '01', ' 2', '2 ', '0x10', '1e3'])( + 'falls back to the first page for the invalid page %j', + (page) => { + expect(readPaging(new URLSearchParams({ page })).page).toBe(1); + }, + ); + + it.each(['abc', '0', '30', '500', '', '020', '10.0', '-10'])( + 'falls back to the default size for %j, which is not one of the offered sizes', + (pageSize) => { + expect(readPaging(new URLSearchParams({ pageSize })).pageSize).toBe(DEFAULT_PAGE_SIZE); + }, + ); + + it.each(PAGE_SIZE_OPTIONS)('accepts the offered size %i', (pageSize) => { + expect(readPaging(new URLSearchParams({ pageSize: String(pageSize) })).pageSize).toBe(pageSize); + }); + + it('refuses a page whose offset would not fit the int the backend counts in', () => { + expect(readPaging(new URLSearchParams('page=21474837&pageSize=100')).page).toBe(21474837); + expect(readPaging(new URLSearchParams('page=21474838&pageSize=100')).page).toBe(1); + expect(readPaging(new URLSearchParams('page=99999999999999999999')).page).toBe(1); + }); + + it('judges the page against the size that is actually used, so an invalid size does not shrink the limit', () => { + expect(readPaging(new URLSearchParams('page=21474838&pageSize=30'))).toEqual({ + page: 21474838, + pageSize: DEFAULT_PAGE_SIZE, + }); + }); +}); + +describe('withPaging', () => { + it('leaves the URL clean when the paging is the default', () => { + const next = withPaging(new URLSearchParams('page=4&pageSize=50'), { + page: 1, + pageSize: DEFAULT_PAGE_SIZE, + }); + + expect(next.toString()).toBe(''); + }); + + it('writes a page other than the first and a size other than the default', () => { + const next = withPaging(new URLSearchParams(), { page: 3, pageSize: 50 }); + + expect(next.toString()).toBe('page=3&pageSize=50'); + }); + + it('keeps every other parameter, in its place', () => { + const next = withPaging(new URLSearchParams('q=ana&status=active'), { page: 2, pageSize: 10 }); + + expect(next.toString()).toBe('q=ana&status=active&page=2&pageSize=10'); + }); + + it('replaces a page and a size already in the URL', () => { + const next = withPaging(new URLSearchParams('page=2&q=ana&pageSize=10'), { + page: 5, + pageSize: 100, + }); + + expect(next.toString()).toBe('page=5&q=ana&pageSize=100'); + }); + + it('does not change the parameters it was given', () => { + const params = new URLSearchParams('page=2'); + + withPaging(params, { page: 3, pageSize: 50 }); + + expect(params.toString()).toBe('page=2'); + }); +}); + +describe('getLastPage', () => { + it.each([ + [0, 20, 1], + [1, 20, 1], + [20, 20, 1], + [21, 20, 2], + [87, 20, 5], + [100, 100, 1], + [101, 100, 2], + ])('has %i items at %i per page ending on page %i', (totalCount, pageSize, expected) => { + expect(getLastPage(totalCount, pageSize)).toBe(expected); + }); +}); + +describe('getVisibleRange', () => { + it('numbers the items of a page from one', () => { + expect(getVisibleRange({ page: 2, pageSize: 20 }, 87)).toEqual({ first: 21, last: 40 }); + }); + + it('stops at the total on the last, partial page', () => { + expect(getVisibleRange({ page: 5, pageSize: 20 }, 87)).toEqual({ first: 81, last: 87 }); + }); + + it('covers the whole list when it fits one page', () => { + expect(getVisibleRange({ page: 1, pageSize: 20 }, 3)).toEqual({ first: 1, last: 3 }); + }); +}); + +describe('getPageWindow', () => { + it.each([ + [1, 1, [1]], + [1, 7, [1, 2, 3, 4, 5, 6, 7]], + [4, 7, [1, 2, 3, 4, 5, 6, 7]], + [1, 8, [1, 2, 3, 4, 5, 'ellipsis', 8]], + [4, 8, [1, 2, 3, 4, 5, 'ellipsis', 8]], + [5, 8, [1, 'ellipsis', 4, 5, 6, 7, 8]], + [8, 8, [1, 'ellipsis', 4, 5, 6, 7, 8]], + [1, 22, [1, 2, 3, 4, 5, 'ellipsis', 22]], + [4, 22, [1, 2, 3, 4, 5, 'ellipsis', 22]], + [5, 22, [1, 'ellipsis', 4, 5, 6, 'ellipsis', 22]], + [11, 22, [1, 'ellipsis', 10, 11, 12, 'ellipsis', 22]], + [18, 22, [1, 'ellipsis', 17, 18, 19, 'ellipsis', 22]], + [19, 22, [1, 'ellipsis', 18, 19, 20, 21, 22]], + [22, 22, [1, 'ellipsis', 18, 19, 20, 21, 22]], + ])('shows, on page %i of %i, %j', (page, lastPage, expected) => { + expect(getPageWindow(page, lastPage)).toEqual(expected); + }); + + it('is never wider than seven slots, wherever the current page is', () => { + for (let page = 1; page <= 40; page += 1) { + expect(getPageWindow(page, 40).length).toBeLessThanOrEqual(7); + } + }); + + it('always shows the first page, the last page and the current one', () => { + for (let page = 1; page <= 40; page += 1) { + const window = getPageWindow(page, 40); + expect(window).toContain(1); + expect(window).toContain(40); + expect(window).toContain(page); + } + }); +}); diff --git a/apps/admin-frontend/src/shared/pagination/paging.ts b/apps/admin-frontend/src/shared/pagination/paging.ts new file mode 100644 index 00000000..96587af6 --- /dev/null +++ b/apps/admin-frontend/src/shared/pagination/paging.ts @@ -0,0 +1,81 @@ +export const FIRST_PAGE = 1; +export const DEFAULT_PAGE_SIZE = 20; +export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const; + +const PAGE_PARAM = 'page'; +const PAGE_SIZE_PARAM = 'pageSize'; +const MAX_OFFSET = 2_147_483_647; +const PAGE_NUMBER = /^[1-9]\d*$/; +const WINDOW_SLOTS = 7; +const EDGE_RUN = 5; + +export interface Paging { + page: number; + pageSize: number; +} + +export interface PagedResult { + items: T[]; + totalCount: number; + page: number; + pageSize: number; +} + +export type PageWindowItem = number | 'ellipsis'; + +export interface VisibleRange { + first: number; + last: number; +} + +function range(start: number, end: number): number[] { + return Array.from({ length: end - start + 1 }, (_, index) => start + index); +} + +function readPageSize(raw: string | null): number { + return PAGE_SIZE_OPTIONS.find((option) => String(option) === raw) ?? DEFAULT_PAGE_SIZE; +} + +function readPage(raw: string | null, pageSize: number): number { + if (raw === null || !PAGE_NUMBER.test(raw)) return FIRST_PAGE; + + const page = Number(raw); + return (page - 1) * pageSize <= MAX_OFFSET ? page : FIRST_PAGE; +} + +export function readPaging(params: URLSearchParams): Paging { + const pageSize = readPageSize(params.get(PAGE_SIZE_PARAM)); + return { page: readPage(params.get(PAGE_PARAM), pageSize), pageSize }; +} + +export function withPaging(params: URLSearchParams, { page, pageSize }: Paging): URLSearchParams { + const next = new URLSearchParams(params); + + if (page === FIRST_PAGE) next.delete(PAGE_PARAM); + else next.set(PAGE_PARAM, String(page)); + + if (pageSize === DEFAULT_PAGE_SIZE) next.delete(PAGE_SIZE_PARAM); + else next.set(PAGE_SIZE_PARAM, String(pageSize)); + + return next; +} + +export function getLastPage(totalCount: number, pageSize: number): number { + return Math.max(FIRST_PAGE, Math.ceil(totalCount / pageSize)); +} + +export function getVisibleRange({ page, pageSize }: Paging, totalCount: number): VisibleRange { + return { first: (page - 1) * pageSize + 1, last: Math.min(page * pageSize, totalCount) }; +} + +export function getPageWindow(page: number, lastPage: number): PageWindowItem[] { + if (lastPage <= WINDOW_SLOTS) return range(FIRST_PAGE, lastPage); + + if (page < EDGE_RUN) return [...range(FIRST_PAGE, EDGE_RUN), 'ellipsis', lastPage]; + + if (page > lastPage - EDGE_RUN + 1) { + return [FIRST_PAGE, 'ellipsis', ...range(lastPage - EDGE_RUN + 1, lastPage)]; + } + + return [FIRST_PAGE, 'ellipsis', page - 1, page, page + 1, 'ellipsis', lastPage]; +} diff --git a/apps/admin-frontend/src/shared/ui/select/components/select-content.tsx b/apps/admin-frontend/src/shared/ui/select/components/select-content.tsx new file mode 100644 index 00000000..5fd5ff7e --- /dev/null +++ b/apps/admin-frontend/src/shared/ui/select/components/select-content.tsx @@ -0,0 +1,43 @@ +import { Select as SelectPrimitive } from '@base-ui/react/select'; +import { cn } from '@/shared/lib/utils'; + +function SelectContent({ + className, + children, + side = 'bottom', + sideOffset = 4, + align = 'center', + alignOffset = 0, + alignItemWithTrigger = false, + ...props +}: SelectPrimitive.Popup.Props & + Pick< + SelectPrimitive.Positioner.Props, + 'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger' + >) { + return ( + + + + {children} + + + + ); +} + +export { SelectContent }; diff --git a/apps/admin-frontend/src/shared/ui/select/components/select-item.tsx b/apps/admin-frontend/src/shared/ui/select/components/select-item.tsx new file mode 100644 index 00000000..346782a8 --- /dev/null +++ b/apps/admin-frontend/src/shared/ui/select/components/select-item.tsx @@ -0,0 +1,29 @@ +import { Select as SelectPrimitive } from '@base-ui/react/select'; +import { CheckIcon } from 'lucide-react'; +import { cn } from '@/shared/lib/utils'; + +function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) { + return ( + + + {children} + + + } + > + + + + ); +} + +export { SelectItem }; diff --git a/apps/admin-frontend/src/shared/ui/select/components/select-primitives.tsx b/apps/admin-frontend/src/shared/ui/select/components/select-primitives.tsx new file mode 100644 index 00000000..3c269b10 --- /dev/null +++ b/apps/admin-frontend/src/shared/ui/select/components/select-primitives.tsx @@ -0,0 +1,24 @@ +import { Select as SelectPrimitive } from '@base-ui/react/select'; +import { cn } from '@/shared/lib/utils'; + +function SelectLabel({ className, ...props }: SelectPrimitive.Label.Props) { + return ( + + ); +} + +function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { + return ( + + ); +} + +export { SelectLabel, SelectValue }; diff --git a/apps/admin-frontend/src/shared/ui/select/components/select-trigger.tsx b/apps/admin-frontend/src/shared/ui/select/components/select-trigger.tsx new file mode 100644 index 00000000..c48e4444 --- /dev/null +++ b/apps/admin-frontend/src/shared/ui/select/components/select-trigger.tsx @@ -0,0 +1,23 @@ +import { Select as SelectPrimitive } from '@base-ui/react/select'; +import { ChevronDownIcon } from 'lucide-react'; +import { cn } from '@/shared/lib/utils'; + +function SelectTrigger({ className, children, ...props }: SelectPrimitive.Trigger.Props) { + return ( + + {children} + } + /> + + ); +} + +export { SelectTrigger }; diff --git a/apps/admin-frontend/src/shared/ui/select/index.tsx b/apps/admin-frontend/src/shared/ui/select/index.tsx new file mode 100644 index 00000000..fa6d6583 --- /dev/null +++ b/apps/admin-frontend/src/shared/ui/select/index.tsx @@ -0,0 +1,10 @@ +import { Select as SelectPrimitive } from '@base-ui/react/select'; + +import { SelectLabel, SelectValue } from './components/select-primitives'; +import { SelectTrigger } from './components/select-trigger'; +import { SelectContent } from './components/select-content'; +import { SelectItem } from './components/select-item'; + +const Select = SelectPrimitive.Root; + +export { Select, SelectLabel, SelectTrigger, SelectValue, SelectContent, SelectItem }; diff --git a/apps/admin-frontend/src/widgets/list-section/index.tsx b/apps/admin-frontend/src/widgets/list-section/index.tsx index 92326f0f..735ab4fb 100644 --- a/apps/admin-frontend/src/widgets/list-section/index.tsx +++ b/apps/admin-frontend/src/widgets/list-section/index.tsx @@ -1,4 +1,5 @@ import { cn } from '@/shared/lib/utils'; +import { Pagination } from '@/widgets/pagination'; import { ListSectionStatus, type ListSectionProps } from './list-section.types'; import { ListSectionSkeleton } from './components/list-section-skeleton'; @@ -15,12 +16,13 @@ function ListSection(props: ListSectionProps) { search, errorTitle, failure, + pagination, 'aria-label': ariaLabel, className, } = props; return ( -
+
{status === ListSectionStatus.Loading && } {status === ListSectionStatus.Error && ( @@ -35,6 +37,9 @@ function ListSection(props: ListSectionProps) { search={search} /> )} + {pagination && status !== ListSectionStatus.Error && ( + + )}
); } @@ -45,5 +50,6 @@ export type { ListSectionColumn, ListSectionEmptyCopy, ListSectionFailure, + ListSectionPagination, ListSectionProps, } from './list-section.types'; diff --git a/apps/admin-frontend/src/widgets/list-section/list-section.test.tsx b/apps/admin-frontend/src/widgets/list-section/list-section.test.tsx index bfa85583..579d1f75 100644 --- a/apps/admin-frontend/src/widgets/list-section/list-section.test.tsx +++ b/apps/admin-frontend/src/widgets/list-section/list-section.test.tsx @@ -240,6 +240,125 @@ describe('ListSection', () => { }); }); + describe('pagination', () => { + const PAGINATION = { + page: 2, + pageSize: 20, + totalCount: 87, + itemLabel: { singular: 'item', plural: 'itens' }, + onPageChange: vi.fn(), + onPageSizeChange: vi.fn(), + }; + + function renderPaginated(props: { + status?: 'ready' | 'loading' | 'error'; + items?: Item[]; + pagination?: typeof PAGINATION | null; + }) { + const { status = 'ready', items = ITEMS, pagination = PAGINATION } = props; + return render( + item.id} + renderItem={(item) => item.name} + pagination={pagination} + aria-label="Itens" + />, + ); + } + + it('shows where the person is, below the items, once the page is ready', () => { + renderPaginated({}); + + const list = screen.getByRole('list', { name: 'Itens' }); + const summary = screen.getByRole('status'); + expect(summary).toHaveTextContent('Exibindo 21–40 de 87 itens'); + expect(list.compareDocumentPosition(summary)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(screen.getByRole('navigation', { name: 'Paginação' })).toBeInTheDocument(); + }); + + it('hands the page and size changes to the list', async () => { + const onPageChange = vi.fn(); + const onPageSizeChange = vi.fn(); + renderPaginated({ pagination: { ...PAGINATION, onPageChange, onPageSizeChange } }); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Próxima' })); + await user.click(screen.getByRole('combobox', { name: 'Itens por página' })); + await user.click(await screen.findByRole('option', { name: '50' })); + + expect(onPageChange).toHaveBeenCalledExactlyOnceWith(3); + expect(onPageSizeChange).toHaveBeenCalledExactlyOnceWith(50); + }); + + it('keeps the controls on screen, disabled, over the skeleton while the next page loads', async () => { + const onPageChange = vi.fn(); + const { container } = renderPaginated({ + status: 'loading', + items: [], + pagination: { ...PAGINATION, onPageChange }, + }); + + expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 21–40 de 87 itens'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Próxima' })); + expect(screen.getByRole('button', { name: 'Próxima' })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + expect(screen.getByRole('combobox', { name: 'Itens por página' })).toBeDisabled(); + expect(onPageChange).not.toHaveBeenCalled(); + }); + + it('enables the controls when the page is ready', () => { + renderPaginated({}); + + expect(screen.getByRole('button', { name: 'Próxima' })).toHaveAttribute( + 'aria-disabled', + 'false', + ); + expect(screen.getByRole('combobox', { name: 'Itens por página' })).toBeEnabled(); + }); + + it('hides the controls when the list failed to load', () => { + renderPaginated({ status: 'error', items: [] }); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.queryByRole('navigation', { name: 'Paginação' })).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('shows no controls when the list has no total to page through', () => { + renderPaginated({ + items: [], + pagination: { ...PAGINATION, page: 1, totalCount: 0 }, + }); + + expect(screen.getByText('Nenhum item encontrado.')).toBeInTheDocument(); + expect(screen.queryByRole('navigation', { name: 'Paginação' })).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('shows no controls when the list does not page, or is told there is nothing yet', () => { + const { unmount } = renderPaginated({ pagination: null }); + expect(screen.queryByRole('navigation', { name: 'Paginação' })).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + unmount(); + + render( + item.id} + renderItem={(item) => item.name} + aria-label="Itens" + />, + ); + expect(screen.queryByRole('navigation', { name: 'Paginação' })).not.toBeInTheDocument(); + }); + }); + it('renders one listitem per item via renderItem in list mode', () => { render( void) | undefined; } +type ListSectionPagination = Omit; + type ListSectionProps = ListSectionRenderMode & { status: ListSectionStatus; items: readonly T[]; @@ -46,6 +49,7 @@ type ListSectionProps = ListSectionRenderMode & { search?: ListSectionSearch | undefined; errorTitle?: string | undefined; failure?: ListSectionFailure | null | undefined; + pagination?: ListSectionPagination | null | undefined; 'aria-label': string; className?: string | undefined; }; @@ -90,6 +94,7 @@ export type { ListSectionEmptyProps, ListSectionErrorProps, ListSectionFailure, + ListSectionPagination, ListSectionSearch, ListSectionProps, ListSectionRenderMode, diff --git a/apps/admin-frontend/src/widgets/pagination/components/pagination-page-size.tsx b/apps/admin-frontend/src/widgets/pagination/components/pagination-page-size.tsx new file mode 100644 index 00000000..a058b66b --- /dev/null +++ b/apps/admin-frontend/src/widgets/pagination/components/pagination-page-size.tsx @@ -0,0 +1,38 @@ +import { PAGE_SIZE_OPTIONS } from '@/shared/pagination/paging'; +import { + Select, + SelectContent, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@/shared/ui/select'; +import type { PaginationPageSizeProps } from '../pagination.types'; + +function PaginationPageSize({ pageSize, onPageSizeChange, disabled }: PaginationPageSizeProps) { + return ( + + ); +} + +export { PaginationPageSize }; diff --git a/apps/admin-frontend/src/widgets/pagination/components/pagination-pages.tsx b/apps/admin-frontend/src/widgets/pagination/components/pagination-pages.tsx new file mode 100644 index 00000000..82b8880a --- /dev/null +++ b/apps/admin-frontend/src/widgets/pagination/components/pagination-pages.tsx @@ -0,0 +1,62 @@ +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import { FIRST_PAGE, getPageWindow } from '@/shared/pagination/paging'; +import { Button } from '@/shared/ui/button'; +import type { PaginationPagesProps } from '../pagination.types'; + +function PaginationPages({ page, lastPage, onPageChange, disabled }: PaginationPagesProps) { + return ( + + ); +} + +export { PaginationPages }; diff --git a/apps/admin-frontend/src/widgets/pagination/components/pagination-summary.tsx b/apps/admin-frontend/src/widgets/pagination/components/pagination-summary.tsx new file mode 100644 index 00000000..969da45b --- /dev/null +++ b/apps/admin-frontend/src/widgets/pagination/components/pagination-summary.tsx @@ -0,0 +1,23 @@ +import { getVisibleRange } from '@/shared/pagination/paging'; +import type { PaginationSummaryProps } from '../pagination.types'; + +const LOCALE = 'pt-BR'; +const numberFormat = new Intl.NumberFormat(LOCALE); +const pluralRules = new Intl.PluralRules(LOCALE); + +function PaginationSummary({ page, pageSize, totalCount, itemLabel }: PaginationSummaryProps) { + const { first, last } = getVisibleRange({ page, pageSize }, totalCount); + const range = + first === last + ? numberFormat.format(first) + : `${numberFormat.format(first)}–${numberFormat.format(last)}`; + const noun = pluralRules.select(totalCount) === 'one' ? itemLabel.singular : itemLabel.plural; + + return ( +

+ {`Exibindo ${range} de ${numberFormat.format(totalCount)} ${noun}`} +

+ ); +} + +export { PaginationSummary }; diff --git a/apps/admin-frontend/src/widgets/pagination/index.tsx b/apps/admin-frontend/src/widgets/pagination/index.tsx new file mode 100644 index 00000000..aa8ddd4c --- /dev/null +++ b/apps/admin-frontend/src/widgets/pagination/index.tsx @@ -0,0 +1,50 @@ +import { PAGE_SIZE_OPTIONS, getLastPage } from '@/shared/pagination/paging'; +import { PaginationPages } from './components/pagination-pages'; +import { PaginationPageSize } from './components/pagination-page-size'; +import { PaginationSummary } from './components/pagination-summary'; +import type { PaginationProps } from './pagination.types'; + +function Pagination({ + page, + pageSize, + totalCount, + itemLabel, + onPageChange, + onPageSizeChange, + disabled, +}: PaginationProps) { + if (totalCount <= 0) return null; + + const lastPage = getLastPage(totalCount, pageSize); + + return ( +
+
+ + {totalCount > PAGE_SIZE_OPTIONS[0] && ( + + )} +
+ {lastPage > 1 && ( + + )} +
+ ); +} + +export { Pagination }; +export type { PaginationItemLabel, PaginationProps } from './pagination.types'; diff --git a/apps/admin-frontend/src/widgets/pagination/pagination.test.tsx b/apps/admin-frontend/src/widgets/pagination/pagination.test.tsx new file mode 100644 index 00000000..a277d7fb --- /dev/null +++ b/apps/admin-frontend/src/widgets/pagination/pagination.test.tsx @@ -0,0 +1,249 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Pagination, type PaginationProps } from './index'; + +const ITEM_LABEL = { singular: 'pessoa', plural: 'pessoas' }; +const PAGE_SIZE_LABEL = 'Itens por página'; + +function renderPagination(props: Partial = {}) { + const onPageChange = vi.fn(); + const onPageSizeChange = vi.fn(); + render( + , + ); + return { onPageChange, onPageSizeChange }; +} + +describe('Pagination', () => { + it('renders nothing when there is nothing to page through', () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + describe('summary', () => { + it('says which items are on screen out of how many, as a status', () => { + renderPagination({ page: 2, pageSize: 20, totalCount: 87 }); + + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 21–40 de 87 pessoas'); + }); + + it('stops at the total on the last, partial page', () => { + renderPagination({ page: 5, pageSize: 20, totalCount: 87 }); + + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 81–87 de 87 pessoas'); + }); + + it('names one item once, not as a range, and in the singular', () => { + renderPagination({ page: 1, pageSize: 20, totalCount: 1 }); + + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 1 de 1 pessoa'); + expect(screen.getByRole('status')).not.toHaveTextContent('pessoas'); + }); + + it('writes the numbers the Brazilian way', () => { + renderPagination({ page: 1, pageSize: 20, totalCount: 1234 }); + + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 1–20 de 1.234 pessoas'); + }); + + it('uses the noun the list gives', () => { + renderPagination({ + page: 1, + pageSize: 20, + totalCount: 3, + itemLabel: { singular: 'serviço', plural: 'serviços' }, + }); + + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 1–3 de 3 serviços'); + }); + }); + + describe('pages', () => { + it('lists the pages around the current one, between Anterior and Próxima', () => { + renderPagination({ page: 5, pageSize: 20, totalCount: 440 }); + + const nav = screen.getByRole('navigation', { name: 'Paginação' }); + const names = within(nav) + .getAllByRole('button') + .map((button) => button.getAttribute('aria-label') ?? button.textContent); + expect(names).toEqual([ + 'Anterior', + 'Ir para a página 1', + 'Ir para a página 4', + 'Página 5', + 'Ir para a página 6', + 'Ir para a página 22', + 'Próxima', + ]); + }); + + it('marks the current page as the current page and no other', () => { + renderPagination({ page: 5 }); + + expect(screen.getByRole('button', { name: 'Página 5' })).toHaveAttribute( + 'aria-current', + 'page', + ); + expect(screen.getByRole('button', { name: 'Ir para a página 4' })).not.toHaveAttribute( + 'aria-current', + ); + }); + + it('hides the gaps from assistive tech', () => { + renderPagination({ page: 5 }); + + const gaps = screen.getAllByText('…'); + expect(gaps).toHaveLength(2); + gaps.forEach((gap) => expect(gap).toHaveAttribute('aria-hidden', 'true')); + }); + + it('also says "Página 5 de 22", for the narrow screens that hide the numbers', () => { + renderPagination({ page: 5, pageSize: 20, totalCount: 440 }); + + expect(screen.getByText('Página 5 de 22')).toBeInTheDocument(); + }); + + it('goes to the page of a number', async () => { + const { onPageChange } = renderPagination({ page: 5 }); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Ir para a página 6' })); + + expect(onPageChange).toHaveBeenCalledExactlyOnceWith(6); + }); + + it('goes to the previous and to the next page', async () => { + const user = userEvent.setup(); + const { onPageChange } = renderPagination({ page: 5 }); + + await user.click(screen.getByRole('button', { name: 'Anterior' })); + await user.click(screen.getByRole('button', { name: 'Próxima' })); + + expect(onPageChange).toHaveBeenNthCalledWith(1, 4); + expect(onPageChange).toHaveBeenNthCalledWith(2, 6); + }); + + it('does nothing when the current page is pressed', async () => { + const { onPageChange } = renderPagination({ page: 5 }); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Página 5' })); + + expect(onPageChange).not.toHaveBeenCalled(); + }); + + it('cannot go back from the first page, but stays focusable', async () => { + const { onPageChange } = renderPagination({ page: 1 }); + const previous = screen.getByRole('button', { name: 'Anterior' }); + + await userEvent.setup().click(previous); + + expect(previous).toHaveAttribute('aria-disabled', 'true'); + expect(onPageChange).not.toHaveBeenCalled(); + previous.focus(); + expect(previous).toHaveFocus(); + }); + + it('cannot go forward from the last page', async () => { + const { onPageChange } = renderPagination({ page: 22, pageSize: 20, totalCount: 440 }); + const next = screen.getByRole('button', { name: 'Próxima' }); + + await userEvent.setup().click(next); + + expect(next).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: 'Anterior' })).toHaveAttribute( + 'aria-disabled', + 'false', + ); + expect(onPageChange).not.toHaveBeenCalled(); + }); + + it('has no pages to go through when everything fits on one', () => { + renderPagination({ page: 1, pageSize: 20, totalCount: 15 }); + + expect(screen.queryByRole('navigation', { name: 'Paginação' })).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 1–15 de 15 pessoas'); + }); + }); + + describe('items per page', () => { + it('shows the current size under its label', () => { + renderPagination({ pageSize: 50, totalCount: 440 }); + + expect(screen.getByRole('combobox', { name: PAGE_SIZE_LABEL })).toHaveTextContent('50'); + }); + + it('offers 10, 20, 50 and 100', async () => { + renderPagination(); + + await userEvent.setup().click(screen.getByRole('combobox', { name: PAGE_SIZE_LABEL })); + + const options = await screen.findAllByRole('option'); + expect(options.map((option) => option.textContent)).toEqual(['10', '20', '50', '100']); + }); + + it('asks for the size picked', async () => { + const user = userEvent.setup(); + const { onPageSizeChange } = renderPagination({ pageSize: 20 }); + + await user.click(screen.getByRole('combobox', { name: PAGE_SIZE_LABEL })); + await user.click(await screen.findByRole('option', { name: '50' })); + + expect(onPageSizeChange).toHaveBeenCalledExactlyOnceWith(50); + }); + + it('is not offered when even the smallest size would show everything', () => { + renderPagination({ page: 1, pageSize: 20, totalCount: 10 }); + + expect(screen.queryByRole('combobox', { name: PAGE_SIZE_LABEL })).not.toBeInTheDocument(); + }); + + it('is offered as soon as the smallest size would split the list', () => { + renderPagination({ page: 1, pageSize: 20, totalCount: 11 }); + + expect(screen.getByRole('combobox', { name: PAGE_SIZE_LABEL })).toBeInTheDocument(); + }); + }); + + describe('disabled', () => { + it('ignores every control, keeping the pager focusable', async () => { + const user = userEvent.setup(); + const { onPageChange, onPageSizeChange } = renderPagination({ page: 5, disabled: true }); + + await user.click(screen.getByRole('button', { name: 'Anterior' })); + await user.click(screen.getByRole('button', { name: 'Próxima' })); + await user.click(screen.getByRole('button', { name: 'Ir para a página 6' })); + + expect(onPageChange).not.toHaveBeenCalled(); + expect(onPageSizeChange).not.toHaveBeenCalled(); + within(screen.getByRole('navigation', { name: 'Paginação' })) + .getAllByRole('button') + .forEach((button) => expect(button).toHaveAttribute('aria-disabled', 'true')); + expect(screen.getByRole('combobox', { name: PAGE_SIZE_LABEL })).toBeDisabled(); + }); + + it('keeps showing where the person is', () => { + renderPagination({ page: 5, pageSize: 20, totalCount: 440, disabled: true }); + + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 81–100 de 440 pessoas'); + expect(screen.getByRole('button', { name: 'Página 5' })).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/admin-frontend/src/widgets/pagination/pagination.types.ts b/apps/admin-frontend/src/widgets/pagination/pagination.types.ts new file mode 100644 index 00000000..2ddd34f1 --- /dev/null +++ b/apps/admin-frontend/src/widgets/pagination/pagination.types.ts @@ -0,0 +1,31 @@ +export interface PaginationItemLabel { + singular: string; + plural: string; +} + +export interface PaginationProps { + page: number; + pageSize: number; + totalCount: number; + itemLabel: PaginationItemLabel; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; + disabled?: boolean | undefined; +} + +export type PaginationSummaryProps = Pick< + PaginationProps, + 'page' | 'pageSize' | 'totalCount' | 'itemLabel' +>; + +export type PaginationPageSizeProps = Pick< + PaginationProps, + 'pageSize' | 'onPageSizeChange' | 'disabled' +>; + +export interface PaginationPagesProps extends Pick< + PaginationProps, + 'page' | 'onPageChange' | 'disabled' +> { + lastPage: number; +} diff --git a/docs/adr/0042-admin-frontend-widgets-layer-and-layer-boundaries-rule.md b/docs/adr/0042-admin-frontend-widgets-layer-and-layer-boundaries-rule.md index ed613b09..ea3c09d5 100644 --- a/docs/adr/0042-admin-frontend-widgets-layer-and-layer-boundaries-rule.md +++ b/docs/adr/0042-admin-frontend-widgets-layer-and-layer-boundaries-rule.md @@ -28,8 +28,9 @@ places. Adding a fourth layer meant another block restating everything. ## Decision 1. **A `src/widgets/` layer** holds generic UI compositions that carry behaviour but no domain - knowledge. Direction: `app → features → widgets → shared`. A widget imports only `shared/`; - a feature may import widgets; nothing imports upward. + knowledge. Direction: `app → features → widgets → shared`. A widget imports `shared/` and, to compose one, + another widget, never in a cycle (amended by [ADR 0068](0068-admin-frontend-list-pagination.md): `list-section` + renders `pagination`); a feature may import widgets; nothing imports upward. 2. **One custom ESLint rule, `agenza/layer-boundaries`** (`apps/admin-frontend/eslint-rules/layerBoundaries.js`, covered by its own `RuleTester` test), replaces the three blocks. It resolves `@/` and relative specifiers — static imports, re-exports @@ -46,6 +47,8 @@ places. Adding a fourth layer meant another block restating everything. Extending the rule's `locate()` is the fix if that ever matters. - Imports inside a slice are exempt in any form, including `@/features//…`, which the old base block banned. +- A widget importing a widget was never what the rule reported (it reports only an import into a higher layer, and a + sibling is the same layer); ADR 0068 writes the convention down instead of the sentence it replaced. - `widgets/` is not sliced: a consumer can import a widget's `components/` directly. The `index.tsx`-only convention for sub-parts stays a review rule, as it is for `shared/ui/`. - Widgets carry behaviour, so by this ADR's own premise they belong under the coverage gate. diff --git a/docs/adr/0067-admin-frontend-clients-list.md b/docs/adr/0067-admin-frontend-clients-list.md index c1110763..3a362d4e 100644 --- a/docs/adr/0067-admin-frontend-clients-list.md +++ b/docs/adr/0067-admin-frontend-clients-list.md @@ -1,7 +1,8 @@ # ADR 0067 — admin-frontend: the Pessoas list Status: accepted (2026-10); consumes the contract of [ADR 0063](0063-clients-list-and-search-contract.md) and amends it -(an omitted `status` lists everyone, point 9), follows +(an omitted `status` lists everyone, point 9); the paging of point 3 arrived with +[ADR 0068](0068-admin-frontend-list-pagination.md); follows [ADR 0033](0033-admin-frontend-no-request-cancellation-layer.md) and [ADR 0035](0035-admin-frontend-no-server-state-library.md) @@ -26,7 +27,8 @@ points of the issue text disagree with that contract or with the state of the ap 2. **The screen reads `?q=` from the URL, as the tags list does, and sends `Search`** to the API. The repository takes only the search term; the backend defaults apply: `page=1`, `pageSize=20` and no `status`, which lists everyone (point 9). -3. **The situation filter and the paging controls are not part of this change.** They arrive with the controls the owner +3. **The situation filter and the paging controls are not part of this change** (the paging came right after, in + ADR 0068; the filter is still open). They arrive with the controls the owner will define. Until then the screen lists the first page (20 people) and nothing on it says there are more. A status line, "Exibindo 20 de 87 pessoas", was built and then removed by the owner. `status` and `page` enter the repository, the URL and the hook together with their controls, not before. @@ -63,7 +65,7 @@ points of the issue text disagree with that contract or with the state of the ap ## Consequences -- Acceptance criteria of #155 **not met by this change**: the situation filter itself ("cadastro inativo aparece quando +- Acceptance criteria of #155 **not met by this change** (all but the filter were met by ADR 0068): the situation filter itself ("cadastro inativo aparece quando o filtro o inclui" as a choice on screen), "alterar busca/filtro reinicia na página 1" for the filter, and "navegação entre páginas não repete nem omite registros". A person past the 20th one is reachable only through the search, with no sign on screen that there are more. diff --git a/docs/adr/0068-admin-frontend-list-pagination.md b/docs/adr/0068-admin-frontend-list-pagination.md new file mode 100644 index 00000000..3d8e378b --- /dev/null +++ b/docs/adr/0068-admin-frontend-list-pagination.md @@ -0,0 +1,100 @@ +# ADR 0068 — admin-frontend: paged lists + +Status: accepted (2026-10); completes the paging that [ADR 0067](0067-admin-frontend-clients-list.md) (point 3) left to +the owner; consumes the paged-list contract of [ADR 0063](0063-clients-list-and-search-contract.md) and +[ADR 0064](0064-paged-reads-page-request-and-paged-result.md); amends +[ADR 0042](0042-admin-frontend-widgets-layer-and-layer-boundaries-rule.md) (a widget may compose another widget); +follows [ADR 0033](0033-admin-frontend-no-request-cancellation-layer.md) and +[ADR 0035](0035-admin-frontend-no-server-state-library.md) + +## Context + +ADR 0067 shipped the Pessoas list with search only: the screen listed the first page (20 people) and nothing on it said +there were more. The owner kept the paging controls for themselves ("the owner defines the select and the paging +controls separately"). On 2026-10-14, right after #177 merged, the owner asked for them, with one condition: minimally +generic, so the next paged list (the services API already pages; Etiquetas does not) reuses the same parts. The owner +told the agent to ask on every doubt instead of assuming, and decided each point below, in answer to questions, before +any code. The backend half needed nothing: `page` from 1, `pageSize` from 1 to 100 (default 20), the answer +`{ items, totalCount, page, pageSize }`, a stable order, and a page past the last answering `200` with empty `items` and +the real total (ADR 0063, ADR 0064). + +## Decision + +1. **The state is in the URL**, beside `?q=`: `?page=` and `?pageSize=`. The defaults (page 1, 20 a page) are left out, + so an untouched list has an untouched URL. Changing the page or the size is a normal navigation, so Back and Forward + walk through the pages. Submitting or clearing the search starts over on page 1 and keeps the size (#155: "alterar + busca/filtro reinicia na página 1; manter os valores ao navegar entre páginas"). +2. **The footer** sits under the table: + - a summary, "Exibindo 21–40 de 87 pessoas" (one item reads "Exibindo 1 de 1 pessoa"; numbers in the Brazilian way, + "1.234"), in a `role="status"` region. The owner had removed an earlier "Exibindo 20 de 87" line because nothing + could move the page then; it came back with the controls; + - a select, **Itens por página**, with 10, 20, 50 and 100 (the default stays 20; 100 is the backend's limit); + - the pager, **Anterior**, the page numbers around the current one with gaps ("1 … 4 [5] 6 … 22", never wider + than seven slots, `aria-current="page"` on the current one) and **Próxima**. Below the `sm` breakpoint the numbers + give way to "Página 5 de 22" between the two buttons. + + Choosing another size goes back to page 1. Derived from "do not show a control that does nothing": no footer without + an item, no select while even 10 a page would show everything (10 items or fewer), no pager with a single page. + +3. **A page or size that cannot be shown is corrected, without a notice.** An unreadable `?page=` (not a positive + integer, or one whose offset `(page - 1) * pageSize` would not fit the `int` the backend counts in) reads as page 1, + and a `?pageSize=` that is not one of the four sizes reads as 20; both are rewritten out of the URL. A page past the + last one (hand-typed, or the total shrank while the person was on the last page) goes to the last page that exists. + The rewrite is a `replace`, so Back never returns to the page that did not exist. Without this, the `200` with empty + `items` would have rendered as "Nenhuma pessoa ainda". While it moves, the screen shows the skeleton, not an empty + list. +4. **While a page loads**, the screen does what a new search already did: the skeleton takes the table's place, and the + footer stays, on the last known total, with every control disabled (the buttons by `aria-disabled`, so keyboard focus + is not lost; the select by `disabled`). Before the first answer, when the total is unknown, and after a failure there + is no footer. Changing the page moves neither the scroll nor the focus. +5. **The reusable parts:** + - `shared/pagination/paging.ts`: the contract of `?page=`/`?pageSize=` as pure functions and constants (`readPaging`, + `withPaging`, `getLastPage`, `getVisibleRange`, `getPageWindow`, `PAGE_SIZE_OPTIONS`, `DEFAULT_PAGE_SIZE`) and the + generic `PagedResult`; tested without mocks; + - `shared/ui/select`: Base UI's Select through the shadcn CLI, trimmed to the five parts the footer uses (label, + trigger, value, content, item). The owner chose it over a native ``** (second kind of select in the app once the situation filter arrives) **and segmented + buttons** (do not fit a phone). +- **A shared `usePagination` hook.** Kept to the rule that hooks are not shared between pages. +- **A `footer` slot on `list-section`**, and **the footer inside `list-section/components/`.** The first splits the + visibility rule between page and widget; the second only serves `list-section` and leaves the logic out of coverage. +- **Keeping the old rows, dimmed, while the next page loads.** Needs a "ready and refreshing" state. +- **A toast, or an empty "this page does not exist" state with a button, for a page that cannot be shown.** +- **Keeping the first item on screen when the size changes** (page 3 of 20 is the 41st item, which is on page 1 of + 50). Back to page 1 is simpler and predictable. +- **Rolling to the top of the list and moving the focus when the page changes.** Not wanted. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7c934ae2..1cabdc61 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -20,7 +20,7 @@ superseded is historical evidence, not current implementation guidance. | Logging and telemetry | 0054 | | admin-frontend architecture | 0033, 0034, 0035, 0036, 0037, 0038 | | admin-frontend UI foundation | 0039, 0040, 0043 | -| admin-frontend screens | 0067 | +| admin-frontend screens | 0067, 0068 | | AI agent instruction files | 0041 | ## Superseded decisions @@ -91,4 +91,5 @@ instruction files reinstated · 0042 widgets layer and layer-boundaries lint rul 0064 paged reads: one page request in, one paged result out · 0065 string value objects read from a query or a route, by opt-in · 0066 enums read from a query or a route by one name · -0067 admin-frontend Pessoas list: whole CPF, filter and paging deferred, provisional detail route. +0067 admin-frontend Pessoas list: whole CPF, filter and paging deferred, provisional detail route · +0068 admin-frontend paged lists: page and size in the URL, one footer shared through ListSection. From 336e1a5d22322b49c6a0ec5cb12d060bcc211fb3 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Wed, 14 Oct 2026 17:27:45 -0300 Subject: [PATCH 02/24] =?UTF-8?q?refactor(admin-frontend):=20rodap=C3=A9?= =?UTF-8?q?=20de=20pagina=C3=A7=C3=A3o=20numa=20linha,=20com=20seletor=20e?= =?UTF-8?q?=20paginador=20sempre=20vis=C3=ADveis=20(#155,=20frontend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O rodapé tinha duas linhas, e o paginador sumia quando a página comportava todos os itens (e o seletor, com 10 itens ou menos). Desenhado com o dono num protótipo de três disposições, ficou a "B". - Uma linha só: resumo à esquerda, paginador no centro e "Itens por página" à direita. As laterais dividem o espaço que o paginador deixa, então ele fica no centro exato quando a linha cabe; quando não cabe, as partes descem em linhas, sem espremer o texto. - O paginador e o seletor ficam sempre visíveis enquanto houver pessoas. Com uma página só, Anterior e Próxima ficam desabilitados e o 1 aparece marcado. - A troca dos números por "Página X de Y" passa a seguir a largura da área de conteúdo (container query, abaixo de 560 px) e não a da janela, já que o menu ocupa uma parte diferente dela em cada modo de navegação. - Testes do widget cobrem a página única, o seletor sempre presente e a ordem resumo, paginador e seletor. O ADR 0068 e o ARCHITECTURE registram a decisão e as alternativas descartadas. Co-Authored-By: Claude Sonnet 5.5 --- apps/admin-frontend/docs/ARCHITECTURE.md | 2 +- .../components/pagination-pages.tsx | 6 +- .../src/widgets/pagination/index.tsx | 36 ++++++----- .../widgets/pagination/pagination.test.tsx | 60 +++++++++++++++---- .../0068-admin-frontend-list-pagination.md | 25 ++++++-- 5 files changed, 87 insertions(+), 42 deletions(-) diff --git a/apps/admin-frontend/docs/ARCHITECTURE.md b/apps/admin-frontend/docs/ARCHITECTURE.md index 66ae2f8e..55415786 100644 --- a/apps/admin-frontend/docs/ARCHITECTURE.md +++ b/apps/admin-frontend/docs/ARCHITECTURE.md @@ -452,7 +452,7 @@ Chosen, and — just as important — tried and backed out of, so nobody re-liti | `features/clients` — the Pessoas list (`/pessoas`, issue #155): search only, with the situation filter and the paging controls deferred | Second list screen, on the shape Tags settled: `useClientListPage` reads `?q=`, calls `clientsRepository.list(search, signal)` from a plain `useEffect` with an `AbortController`, and derives `status` by comparing the in-flight query with the last resolved one. The page is a shell over `ListSection`, with `clientColumns` (Nome, Telefone, E-mail, CPF, Situação) and, through `list-section`, `errorTitle` and `failure` for the error state, plus `search` and `emptyCopy` for the two empty states. Decided with the owner on 2026-10-13, before any code ([ADR 0067](../../../docs/adr/0067-admin-frontend-clients-list.md)): the CPF shows **whole and formatted** (`maskCpf`, which formats and hides nothing), following ADR 0063 over the text of #155 and #139; the filter and the paging controls wait for the owner to define them, so the screen lists the first page (20 people) with no sign that there are more (a "Exibindo N de M" line was built and then removed), the repository takes only `search`, and `status`/`page` arrive together with their controls; each name links to `/pessoas/:id`, a provisional "Em breve" page (`app/pages/ClientDetails.tsx`) until #167 replaces it. Empty is two states that `list-section` itself renders from the page's `emptyCopy` and `search` instead of its fixed message: nobody yet (no button, the header already has **Nova pessoa**) and a search that found nobody (**Limpar busca**). `list` forwards the whole page verbatim, like `create` (`{ items, totalCount, page, pageSize }`); the model types the three paging fields as `number`, and nothing reads them yet. The search form and the hook's URL and abort logic are the second copy of Tags' and stay copied until a third list (§6). `app/pages/Clients.tsx` is retired. Acceptance criteria of #155 not met yet: an inactive person appearing through the filter (moving between pages arrived right after, in the next rows). | | `shared/ui/optional-text`: one way to show a value that may be missing, with an optional `fallback` | Asked for, right after the Pessoas list, when its page-local `OptionalText` (a dash hidden from assistive tech plus "Não informado" for it) turned out to be a second spelling of what `tagColumns` already wrote by hand for the description ("Sem descrição", faded italic, nothing for a screen reader). Nothing in `shared/ui`, `widgets` or `shared/format` covered it: `EmptyState` is a block for an empty list and `shared/values/optionalValue` parses an optional form field. One component, two looks: with no `fallback` it shows the dash, so a table of fixed-format values (phone, e-mail, CPF) stays aligned and a screen reader still hears that the value is missing; with a `fallback` it shows that text, visible, faded and italic, for a free-text field where a word reads better than a dash. `className` goes to every look, so the caller decides things the component cannot know: `whitespace-nowrap` for a phone, `text-sm text-muted-foreground` for a description (the old Pessoas copy forced `nowrap`, which would have been wrong for free text). Only `null` and `undefined` count as missing, as `tag.description ?? 'Sem descrição'` already did. Pessoas and Etiquetas both use it; the Etiquetas tests are unchanged and prove the text stayed. Not excluded from the coverage gate: it has a branch per look and its own test asserts each one. | | `list-section` gains the error state and the two empty variants every list has, driven by `errorTitle` and `failure` (`{ message, code, onRetry }`), `search` (`{ term, onClear }`) and `emptyCopy`; `ClientListResults` and `ClientListEmpty` removed | Asked for, right after the Pessoas list, in three steps. First, the same "ready and zero items" condition was written twice (`ListSectionReady` for the fixed message, `ClientListResults` for the page's own), so `list-section` got `empty` and `error` slots. Then the empty slot was folded into the widget: "nothing yet" versus "the search found nothing, with **Limpar busca**" is behaviour every list has, and Tags would otherwise write a second copy. Then the error slot got the same treatment: the page was building an `ErrorState` from the failure's message, code and retry, which every list that can fail repeats. The widget still decides _when_ (`status` and `items.length`, the check that must not run before the fetch is known to have succeeded), and now _which variant_ (a non-empty `search.term`) and how a failure is laid out; the page supplies the words as ready phrases, not a noun plus a gender, because the copy cannot be derived ("Nenhuma pessoa" against "Nenhum serviço", "ativa", the e-mail and CPF hint) and a grammar helper would still need the same escape hatches. Every field is optional, so a list that passes nothing keeps "Nenhum item encontrado." and "Não foi possível carregar.". `TagListPage` moved onto `errorTitle`, `failure`, `search` and `emptyCopy` right after Pessoas ("Nenhuma etiqueta ainda"; "Nenhuma etiqueta encontrada para «x»" with **Limpar busca**), so its own `ErrorState` ternary and the generic empty message are gone, and `onClearSearch` joined its hook. Both slots were removed once the built-in variants left them without a reader, which §1 treats as dead API. This reverses the §2 sentence that neither state is a prop the page configures, and the cost it listed (no action for a per-feature "Limpar busca", no retry on a failed load). Known boundary: a status filter will make "nothing yet" depend on the filter ("Nenhuma pessoa inativa ainda"), which the page can express through `emptyCopy.title`; clearing a filter, as opposed to a search, is not covered yet. Tests assert each effect: copy and details replace the defaults in their own state, `onClear` and `onRetry` fire, the retry button exists only with an `onRetry`, and nothing shows while loading or ready. | -| `features/clients` — the Pessoas list pages (issue #155, [ADR 0068](../../../docs/adr/0068-admin-frontend-list-pagination.md)): `?page=` and `?pageSize=` in the URL, a footer under the table, `shared/pagination`, `shared/ui/select`, `widgets/pagination` and a `pagination` prop on `list-section` | Asked for right after #177 merged, with one condition: minimally generic, for the next paged list (the services API already pages; Etiquetas does not, so `TagListPage` is untouched). Decided with the owner in three rounds of questions before any code; ADR 0068 holds the answers and the options they turned down. The footer is a summary ("Exibindo 21–40 de 87 pessoas", a status region), **Itens por página** (10, 20, 50, 100; 20 by default) and a pager (**Anterior**, the numbers around the current page with gaps, **Próxima**; "Página 5 de 22" below `sm`). What shaped the code. **(1)** The rule for _when_ the footer shows follows from `status` (never on a failure, disabled while loading, nothing without a total), which every paged list would repeat, so it lives in `list-section`'s `pagination` prop the way the empty and error variants do; the page passes the numbers, the noun (`itemLabel`) and two callbacks. **(2)** The URL contract is pure functions in `shared/pagination/paging.ts` (read with a fallback to the default, write leaving the defaults and every other parameter alone, last page, visible range, the window of numbers), tested without mocks; each list's hook ties `useSearchParams` to them (about ten lines), because hooks are not shared between pages (§1) and the owner kept that rule over a generic `usePagination`. **(3)** `useClientListPage` keys its result by the whole request (`search`, `page`, `pageSize`) as the tags hook keys by `query`, keeps the last `totalCount` in its own state so the footer stays on screen, disabled, over the skeleton while the next page loads, and shows no footer before the first answer or after a failure. **(4)** A page that cannot be shown is corrected by one effect that compares the URL with its canonical form and `replace`s it: an unreadable `page` or `pageSize` reads as the default, and a page past the last one (the backend answers `200` with empty `items` and the real total) goes to the last that exists; while it moves the hook reports loading, never an empty list, because "Nenhuma pessoa ainda" would be a lie. **(5)** The pager's buttons use Base UI's `focusableWhenDisabled` (`aria-disabled`), so focus survives reaching the first or last page and a load in flight; the select only has `disabled`. `shared/ui/select` is the shadcn CLI's `select.tsx` trimmed to the five parts the footer uses, with the `cn` import rewritten, the `tw-animate-css` classes converted to `data-starting-style` / `data-ending-style`, and the stray `cn` dependency uninstalled (`package.json` and the lockfile came out unchanged). The page test serves a fake backend that pages, and answers a page past the last like the real one. | +| `features/clients` — the Pessoas list pages (issue #155, [ADR 0068](../../../docs/adr/0068-admin-frontend-list-pagination.md)): `?page=` and `?pageSize=` in the URL, a footer under the table, `shared/pagination`, `shared/ui/select`, `widgets/pagination` and a `pagination` prop on `list-section` | Asked for right after #177 merged, with one condition: minimally generic, for the next paged list (the services API already pages; Etiquetas does not, so `TagListPage` is untouched). Decided with the owner in three rounds of questions before any code; ADR 0068 holds the answers and the options they turned down. The footer is one line: a summary on the left ("Exibindo 21–40 de 87 pessoas", a status region), the pager in the middle (**Anterior**, the numbers around the current page with gaps, **Próxima**; "Página 5 de 22" in a content area narrower than 560 px, by container query) and **Itens por página** on the right (10, 20, 50, 100; 20 by default). The side parts take the room the pager leaves, so it stays in the middle when the line holds all three, and the parts come down one per line when it does not. The pager and the select stay while the list has an item (with one page the arrows are disabled and the 1 is marked); the owner ruled that after seeing a first version that hid them. What shaped the code. **(1)** The rule for _when_ the footer shows follows from `status` (never on a failure, disabled while loading, nothing without a total), which every paged list would repeat, so it lives in `list-section`'s `pagination` prop the way the empty and error variants do; the page passes the numbers, the noun (`itemLabel`) and two callbacks. **(2)** The URL contract is pure functions in `shared/pagination/paging.ts` (read with a fallback to the default, write leaving the defaults and every other parameter alone, last page, visible range, the window of numbers), tested without mocks; each list's hook ties `useSearchParams` to them (about ten lines), because hooks are not shared between pages (§1) and the owner kept that rule over a generic `usePagination`. **(3)** `useClientListPage` keys its result by the whole request (`search`, `page`, `pageSize`) as the tags hook keys by `query`, keeps the last `totalCount` in its own state so the footer stays on screen, disabled, over the skeleton while the next page loads, and shows no footer before the first answer or after a failure. **(4)** A page that cannot be shown is corrected by one effect that compares the URL with its canonical form and `replace`s it: an unreadable `page` or `pageSize` reads as the default, and a page past the last one (the backend answers `200` with empty `items` and the real total) goes to the last that exists; while it moves the hook reports loading, never an empty list, because "Nenhuma pessoa ainda" would be a lie. **(5)** The pager's buttons use Base UI's `focusableWhenDisabled` (`aria-disabled`), so focus survives reaching the first or last page and a load in flight; the select only has `disabled`. `shared/ui/select` is the shadcn CLI's `select.tsx` trimmed to the five parts the footer uses, with the `cn` import rewritten, the `tw-animate-css` classes converted to `data-starting-style` / `data-ending-style`, and the stray `cn` dependency uninstalled (`package.json` and the lockfile came out unchanged). The page test serves a fake backend that pages, and answers a page past the last like the real one. | | A widget may import another widget (`list-section` renders `pagination`); §1 and [ADR 0042](../../../docs/adr/0042-admin-frontend-widgets-layer-and-layer-boundaries-rule.md) no longer say a widget imports only `shared/` | Asked for, in the paging round: the footer had to be a widget of its own (usable without `list-section`, and inside the coverage gate) and `list-section` had to show it, which the sentence forbade. The lint rule never did: it reports only an import into a higher layer, and a sibling is the same layer. The options turned down were a `footer` slot the page fills (the visibility rule split between page and widget) and the footer inside `list-section/components/` (only `list-section` could use it, and `src/widgets/list-section/**` sits outside the coverage gate). No cycle may form: `pagination` imports `shared/` only. | The palette opens from the header button. The single-use `LinkButton` wrapper was removed; its remaining link uses `buttonVariants` directly. The combobox keeps only the parts used by the palette, and those parts are in the coverage gate. diff --git a/apps/admin-frontend/src/widgets/pagination/components/pagination-pages.tsx b/apps/admin-frontend/src/widgets/pagination/components/pagination-pages.tsx index 82b8880a..eed66aac 100644 --- a/apps/admin-frontend/src/widgets/pagination/components/pagination-pages.tsx +++ b/apps/admin-frontend/src/widgets/pagination/components/pagination-pages.tsx @@ -5,7 +5,7 @@ import type { PaginationPagesProps } from '../pagination.types'; function PaginationPages({ page, lastPage, onPageChange, disabled }: PaginationPagesProps) { return ( -