diff --git a/.claude/skills/agenza-frontend-slice/SKILL.md b/.claude/skills/agenza-frontend-slice/SKILL.md index 6c0b7974..bdf65402 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 @@ -70,8 +70,9 @@ page-owned state. Nothing implements this yet; you are writing the first one. Sh ## 4. Pages are shells `.tsx` holds no `useState`, `useEffect` or `useRef` of its own. All of it lives in that page's -**own** hook. Hooks are never shared between pages — the single exception is a pure Context accessor -like `useAuth`. Two pages wanting the same logic means the logic is a pure function for `model/`, not +**own** hook. Hooks are never shared between pages — the exceptions are a pure Context accessor +like `useAuth` and a generic hook with no domain in a widget, such as `usePaginatedList` (ADR 0071), which +a paged list's hook calls with its initial filters and repository `list` (ADR 0072: the hook keeps that state, not the URL). Two pages wanting the same logic means the logic is a pure function for `model/`, not a shared hook. Details, sub-component placement and the StrictMode guards: [references/pages.md](references/pages.md). diff --git a/.claude/skills/agenza-frontend-slice/references/pages.md b/.claude/skills/agenza-frontend-slice/references/pages.md index 0ce6bea2..8e0012b4 100644 --- a/.claude/skills/agenza-frontend-slice/references/pages.md +++ b/.claude/skills/agenza-frontend-slice/references/pages.md @@ -30,8 +30,11 @@ single-use code) with a terse *why* comment. That guard has to live somewhere it without rendering a route tree — the hook. Spread across a component body, it becomes a race you rediscover in production. -**Hooks are never shared between pages.** The one exception is a pure Context accessor like -`useAuth`. If two pages want the same behaviour, extract the *pure* part into `model/` as a plain +**Hooks are never shared between pages.** The exceptions are a pure Context accessor like +`useAuth`, and a generic hook with no domain that a widget exports, such as `usePaginatedList` in +`widgets/paginated-list-section` (ADR 0071): a paged list declares its filters in `model/` as an +interface, and its page hook passes the initial filters and the repository's `list` to +`usePaginatedList`, which keeps the filters, the page and the size in its own state (ADR 0072). If two pages want the same behaviour, extract the *pure* part into `model/` as a plain function and let each hook call it. A `useSharedThing` imported by two pages is the smell that ends with one page's lifecycle quietly driving another's. diff --git a/.claude/skills/agenza-ptbr-copy/SKILL.md b/.claude/skills/agenza-ptbr-copy/SKILL.md index ba4db7a8..bb92c575 100644 --- a/.claude/skills/agenza-ptbr-copy/SKILL.md +++ b/.claude/skills/agenza-ptbr-copy/SKILL.md @@ -84,7 +84,7 @@ i18n layer to "prepare" for a language nobody asked for. The one exception: a **code → message map is data, not markup**. It belongs in the slice's `model/`, where it is pure and testable without React, next to the branch that consumes it. -**Formatters live in `src/shared/format/`** (`date.ts`, `age.ts`) — cross-cutting, no business rule, +**Formatters live in `src/shared/format/`** (`date.ts`, `age.ts`, `count.ts`) — cross-cutting, no business rule, so `shared/` by the dependency direction in [`docs/ARCHITECTURE.md` §1](../../../apps/admin-frontend/docs/ARCHITECTURE.md). A value with its own validation and limits (CPF, phone, e-mail, birth date) is not a formatter: its mask and parse live with diff --git a/.claude/skills/agenza-ptbr-copy/references/formatting.md b/.claude/skills/agenza-ptbr-copy/references/formatting.md index d3b08187..f2877740 100644 --- a/.claude/skills/agenza-ptbr-copy/references/formatting.md +++ b/.claude/skills/agenza-ptbr-copy/references/formatting.md @@ -130,7 +130,8 @@ whether she has to leave now. Use `Intl.PluralRules(LOCALE)` rather than `n === 1`. CLDR's rule for `pt` is `i = 0..1 → one`, so `select(0)` returns `'one'` — the opposite of the English instinct, and worth confirming in a -one-liner before you depend on it. +one-liner before you depend on it. In `admin-frontend` that is `formatCount(count, { singular, plural })` +in `shared/format/count.ts` ("1 pessoa", "1.234 pessoas"), beside `formatNumber`. Better still: **never render a zero count.** Zero is an empty state, and "Nenhum serviço ainda" says more than "0 serviços". → [`states.md`](states.md) diff --git a/.claude/skills/agenza-testing/references/patterns.md b/.claude/skills/agenza-testing/references/patterns.md index bd5bc864..7058c328 100644 --- a/.claude/skills/agenza-testing/references/patterns.md +++ b/.claude/skills/agenza-testing/references/patterns.md @@ -33,6 +33,9 @@ and TypeScript keeps the literal types. Fixture builders live in [`src/test/oidcUser.ts`](../../../../apps/admin-frontend/src/test/oidcUser.ts) (`makePrincipal`, `makeOidcUser`, `makeAccessToken`). Add to that file rather than hand-rolling a principal in a test; a token built by hand tends to be missing exactly the claim under test. +Beside it, `src/test/pending.ts` gives a promise the test resolves by hand (`pending()`, for an +answer that arrives after another) and `src/test/footerSkeleton.ts` finds the paged list's footer +skeleton; import them instead of declaring a copy in the test file. --- @@ -224,6 +227,9 @@ async function loginAsDemoUser(page: Page) { Rules that keep these green: +- **Read the demo login from the seed's own config.** `DEMO_EMAIL` and `DEMO_PASSWORD` come from + `DemoTenant` in identity-service's `appsettings.Development.json`, the values its seeder creates the + user from; never type them again in a spec. - **Read seeded ids from the running system.** The demo tenant's id is generated fresh on every seed; the live file decodes it out of the issued access token instead of asserting a constant. - **Prefer a reload over waiting out a real clock.** Session persistence is proven by reloading and diff --git a/apps/admin-frontend/AGENTS.md b/apps/admin-frontend/AGENTS.md index c0dd9100..da4caa6a 100644 --- a/apps/admin-frontend/AGENTS.md +++ b/apps/admin-frontend/AGENTS.md @@ -38,7 +38,17 @@ ainda. Crie-os quando uma segunda feature precisar da mesma entidade, não antes **Páginas são cascas.** `.tsx` não tem `useState`, `useEffect` nem `useRef` próprios; toda a lógica mora no hook **daquela** página (`use.ts`). Hooks não são compartilhados entre páginas — -a única exceção é um acessor puro de contexto como `useAuth`. +as exceções são um acessor puro de contexto como `useAuth` e um hook genérico, sem domínio, num widget, como o +`usePaginatedList` de `widgets/paginated-list-section` ([ADR 0071](../../docs/adr/0071-admin-frontend-paged-list-hook.md)). +Uma lista paginada declara a interface dos seus filtros em `model/`, e o hook da página só passa os filtros +iniciais e o repositório ao `usePaginatedList`, que guarda filtros, página e tamanho no próprio estado, fora da URL +([ADR 0072](../../docs/adr/0072-admin-frontend-paged-list-state-in-the-hook.md)). + +**Buscar no backend é uma ação, não uma reação.** O `usePaginatedList` só pede uma página quando uma ação manda +(`applyFilters`, troca de página ou de tamanho, "Tentar novamente"), na montagem e para corrigir uma página além da +última. Nenhum efeito depende do estado dele: não acrescente um `useEffect` com dependências que dispare +`fetchPage`, e uma ação nova passa pelo `request` do hook, conforme a +[ADR 0073](../../docs/adr/0073-admin-frontend-paged-list-asks-only-when-told.md). **`loader` e `action` moram em `ui/pages//route.ts`** e são reexportados pelo barril, para que `app/routes.tsx` os importe via `@/features/` e a direção continue `app → features`. diff --git a/apps/admin-frontend/docs/ARCHITECTURE.md b/apps/admin-frontend/docs/ARCHITECTURE.md index 1f6518ff..72a80c80 100644 --- a/apps/admin-frontend/docs/ARCHITECTURE.md +++ b/apps/admin-frontend/docs/ARCHITECTURE.md @@ -33,8 +33,10 @@ 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, paginated-list-section, page-header, page-section, +│ form-footer, pagination); +│ import shared/ and, to compose one, another widget; consumed by +│ features (ADR 0042, ADR 0070) └── shared/ Cross-cutting, no business logic ├── api/ servicesFacade, servicesApi (its composition), apiClient, │ unwrap (Result → exception at the framework boundary), formErrors @@ -49,8 +51,13 @@ 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: what every paged list shares (first page, default size, + │ last page, Paging and PagedResult); no React (§5). The pager's own + │ math (the window of page numbers, the offered sizes) is in + │ widgets/pagination ├── format/ Policy-free calendar and display helpers: date.ts (utcDate, todayUtc, - │ dd/mm/aaaa mask/parse/format, fullYearsBetween) and age.ts + │ dd/mm/aaaa mask/parse/format, fullYearsBetween), age.ts and count.ts + │ (pt-BR numbers, and "1 pessoa" / "87 pessoas" by Intl.PluralRules) ├── values/ One file per shared value, mirroring the backend's │ SharedKernel.ValueObjects (cpf, phone, email, birthDate): its parseX │ (canonical value or null, like the backend's Create), limits, rules and @@ -73,7 +80,10 @@ 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: + `paginated-list-section` renders `list-section` and `pagination`, + [ADR 0070](../../../docs/adr/0070-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 @@ -96,8 +106,9 @@ proven by its mock-free tests, not by its folder name. **Route pages are shells.** `.tsx` holds no `useEffect`/`useState`/`useRef` of its own; all effect and state logic lives in that page's **own** hook (`useLoginRedirect`, `useAuthCallback`, -…). Hooks are never shared between pages — the one exception is a pure Context accessor like -`useAuth`. Sub-components go in a `components/` subfolder, created only when a page actually grows +…). Hooks are never shared between pages — the exceptions are a pure Context accessor like +`useAuth` and a generic hook with no domain exported by a widget, such as `usePaginatedList` +([ADR 0071](../../../docs/adr/0071-admin-frontend-paged-list-hook.md)). Sub-components go in a `components/` subfolder, created only when a page actually grows them. **A page's `loader` and `action`, when it has them, live in `ui/pages//route.ts`** and are @@ -117,6 +128,12 @@ reason — §5 has the full history of all three. **Filter or search state the person expects to survive a reload, or share as a link, belongs in the URL** (`useSearchParams()`), independent of whether the page has a `loader` — a page-owned `useEffect` fetch reads and writes it exactly as a `loader`-driven page would, it just isn't blocked on it. +**Paged lists are the exception**: `usePaginatedList` keeps the filters, the page and the size in its +own state, so Pessoas starts over on a reload, from a link and on the way back from a person +([ADR 0072](../../../docs/adr/0072-admin-frontend-paged-list-state-in-the-hook.md)). It asks the backend +only when told to: from an action (`applyFilters`, a page, a size, the retry), once on mount and to correct a +page past the end, never from an effect keyed on its state +([ADR 0073](../../../docs/adr/0073-admin-frontend-paged-list-asks-only-when-told.md)). **Every `shared/ui/` component is a folder, not a flat file** — the same pasta-por-unidade instinct as the page pattern above, extended to primitives (`specs/005-shared-ui-component-folders/`). @@ -316,7 +333,14 @@ 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 `paginated-list-section`'s own `pagination` prop +(`{ page, pageSize, totalCount, onPageChange, onPageSizeChange }`, with `totalCount` `null` until +the first answer), because _when_ the footer shows follows from `status` and the total (never on a failure, +nothing without a total) and every paged list would repeat that. That widget renders a +`list-section` with the footer under it, so `list-section` itself knows nothing of paging, and it draws the +skeleton at the page size. The page passes the `pagination` that `usePaginatedList` returns as it is, and the +noun as `itemLabel` (`{ singular, plural }`), a prop of its own beside `emptyCopy` and `errorTitle`, because it +is copy ([ADR 0070](../../../docs/adr/0070-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 — @@ -426,9 +450,20 @@ 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 0070](../../../docs/adr/0070-admin-frontend-list-pagination.md)): `?page=` and `?pageSize=` in the URL, a footer under the table, `shared/pagination`, `shared/ui/select`, `widgets/pagination` and `widgets/paginated-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 0070 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. On a phone (a list area under 560 px) it is two lines: the summary and the select ("Por página", with the full name kept for assistive tech) on the first, and the pager on the second with Anterior and Próxima at the edges; the controls are 44 px tall on a touch screen (`pointer-coarse`), and the DOM order is the same at every width, so the keyboard order does not change. Chosen from a prototype of four options. What shaped the code. **(1)** The rule for _when_ the footer shows follows from `status` (never on a failure, nothing without a total), which every paged list would repeat, so it lives in `paginated-list-section`, which renders a `list-section` with the footer under it (first built as a `pagination` prop on `list-section`, split out on 2026-10-15: see the last row); 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, usable, 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. `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 (`paginated-list-section` renders `list-section` and `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 a list, and inside the coverage gate) and something had to put it under a `list-section` (first `list-section` itself, then `paginated-list-section`), 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: `list-section` and `pagination` import `shared/` only. | +| The shell's outermost element is `relative overflow-clip` (`AppShell`) | Reported by the owner on 2026-10-14, testing the Pessoas list in a phone-sized DevTools window: the bottom navigation was off the screen whenever the table scrolled sideways and back whenever it did not. `OptionalText` writes "Não informado" as an `sr-only` span, which is `position: absolute`; with no positioned ancestor its containing block is the page, so neither the table's `overflow-x-auto` nor `
`'s `overflow-y-auto` clips it, and with a hidden column empty (the CPF) it sat past the right edge, adding about 30 px of width and up to a screen of height to the **document**. A phone browser answers that by making the layout viewport as large as the document, so the `fixed bottom-0` bar lands at the bottom of a screen taller than the real one: 10 px of it showed with ten rows, none with more. Measured on a 440×860 touch-emulated viewport, `relative` alone and `overflow-clip` alone both left it at 468×913; together the document stays 440×860, the bar's bottom is the screen's, and the boxes of the header, side menu, `
`, table and footer are identical at 390, 768, 900, 1024 and 1280 px. `clip`, not `hidden`, so the shell is never a scroller that focus or `scrollIntoView` could move. Fixed in the shell and not in `OptionalText` because any other absolute or `sr-only` content in a scrolling wrapper would bring it back (Tags has the same cell). jsdom has no layout, so `AppShell.test.tsx` pins the two classes; what failed before is the viewport check above (`innerWidth` equal to the screen, the bar inside it). | +| Two list widgets: `list-section`, which knows nothing of paging, and `paginated-list-section`, which renders a `list-section` with the footer under it; `list-section` lost its `pagination` prop and `skeletonRowCount={pageSize}` left the page | Asked for by the owner on 2026-10-15, after the first version had put `pagination` on `list-section` ([ADR 0070](../../../docs/adr/0070-admin-frontend-list-pagination.md), points 4 to 6). Decided in answer to questions: one component for the page, with the props of a `list-section` plus `pagination` (and not a box that takes the list as `children`, which would pass `status` twice and leave the skeleton's size to the page); a folder of its own, because `src/widgets/list-section/**` is outside the coverage gate and the rule for when the footer shows is logic (never on a failure or without a total); and the skeleton drawn at the page size by the widget itself. The last point is what the split bought. `pagination` used to be absent until the first answer (no total, no footer), so `list-section` could not know the size on the first load and the page passed `skeletonRowCount={pageSize}` (the version in between, which every next paged list would have had to repeat). Now `pagination.totalCount` is `number \| null`: the footer waits for the number, the size is there from the first render, and `paginated-list-section` does not accept `skeletonRowCount` at all. `list-section` is back to what #177 shipped. With 100 a page the skeleton has 100 rows, about 4,100 px. | +| The loading state of a table list imitates the table: the real headers and one row per item, each `ListSectionColumn` giving its own `skeleton` cell (a generic bar when it gives none) | Asked for by the owner on 2026-10-15, who saw the skeleton taller than the rows that replaced it, so the footer and the scroll jumped when they arrived. Measured in the owner's browser, with the real data and a slow backend, in a 1,024 px window: 901 px of skeleton against 1,085 px of table with 20 a page, 2,251 against 3,117 with 50. The skeleton was a strip every list shared (rows of 45 px, no header), and the table has a 37 px header and rows of 42 px in Pessoas and 49 px in Etiquetas, so no single strip could fit both. Now `list-section` draws the skeleton through `ListSectionTable` itself, with placeholder items, so the header and the paddings are the table's own. The height of a row then comes from the cells' content, which only the list knows, so each column says what its placeholder is (Pessoas: bars the width of a name, a phone, an e-mail and a CPF, and a status pill; Etiquetas: a chip and two 28 px squares for the actions). The bar widths follow the real content, which keeps the headers within 16 px of where they settle (up to 54 px with bars of one width). The status `Badge` got `align-top`, because an inline-flex in a line of text makes the line 21 px, which put a Pessoas row at 42 px against 41 for every other cell; now every row is 41 px. The placeholder table is `aria-hidden` and `pointer-events-none` inside the `aria-busy` region, so assistive tech does not read an empty table and the rows do not highlight. Measured on 20 and 50 a page in Pessoas and on 10 tags: no difference in height from 1,280 px up (858 against 858 px in the owner's browser, with the side menu hidden). A name that wraps (a narrow layout or a phone, rows of 61, 81 and 101 px) still makes the table taller than the skeleton, and by a little more than before because the skeleton row went from 45 to 41 px; the owner kept the wrapping over names on one line (every row 41 px at any width, at the cost of more horizontal scroll). A list in `renderItem` mode keeps the strip. | +| The footer stays usable while a page loads: `PaginatedListSection` no longer disables the pager and the select, and `Pagination` has no `disabled` | Asked for by the owner on 2026-10-15: having to wait for a page to arrive before asking for the next one is a needless block. Nothing needed the protection. `useClientListPage` already keys its result by the whole request and aborts the one in flight when `page`, `pageSize` or `q` change, so a click while a page loads asks for another page and the answer still in flight is dropped, never shown (the same thing a new search already did). The footer shows where the person is going (the summary and the marked number follow the address), so Próxima steps from there. The skeleton stays across the clicks, since the new request has no answer yet. What the owner gets: clicking Próxima three times quickly asks for pages 2, 3 and 4 and shows page 4; changing the size while a page loads goes back to page 1 at the new size. What it costs: a double click on Próxima goes two pages on, and Back walks through every page that was asked for, loaded or not, because each click stays a normal navigation. Anterior on the first page and Próxima on the last stay `aria-disabled`, which keeps focus on them. `disabled` was removed from `Pagination`, `PaginationPages` and `PaginationPageSize` as it had no other user. The tests that pinned the old behavior now pin the new one in the page (two quick clicks, size change during a load, the signal of the dropped request aborted, its late answer ignored), in `paginated-list-section` and in `pagination`. | +| `usePaginatedList` in `widgets/paginated-list-section` runs every paged list: each list reads its own filters (`model/Filters.ts`, an interface and a plain `readFilters(params)`), `shared/pagination/filters.ts`'s `withFilters` writes them; the footer is a skeleton (`PaginationSkeleton`, on the footer's own `PaginationLayout`) while the total of the current filters is unknown | Asked for by the owner on 2026-10-15: `useClientListPage` had grown to 124 lines doing eight things, two of them about people, and the next lists bring other filters (flags, one or several choices, several fields). Reached in conversation, the owner turning down each heavier version in turn: a schema with inferred types, then helpers per kind of filter (`readText`, `readChoice`), then the filter bar owning the reading of the URL (the hook needs the filters for the first request of a link and on Back, and `api/` needs their type). What stayed is one interface and one reading function per list, in `model/`, and a generic hook that owns the URL, the fetch and its abort, the answer, the total, the correction of the URL and the retry; the page's own hook stays and keeps the search field (ref and effect, kept by the owner over React Hook Form). It is the second exception to "hooks are never shared between pages". Two defects of the old hook are fixed and pinned by tests that fail without the fix: an old answer coming back when the request returned to one already on screen (A, B, A before B answered; it chose the answer by comparing values), and a footer reading "Exibindo 41–1 de 1 pessoa" on Back (the last total of any search). The total now belongs to the filters it was counted for: a change of page keeps the footer on it, a new search, Back to other filters and the first load draw the footer's skeleton (the owner's ask; the first load is the same state). Only Pessoas moved, with `q` alone; Etiquetas does not page. [ADR 0071](../../../docs/adr/0071-admin-frontend-paged-list-hook.md) has the options turned down. | +| `usePaginatedList({ initialFilters, fetchPage })` keeps the filters, the page and the size in a state of its own and no longer touches the URL; `readClientListFilters`, `shared/pagination/filters.ts`, `readPaging` and `withPaging` are gone | Asked for by the owner on 2026-10-19: the hook read and rewrote the query string, so it could only be the main list of a route (a list in a dialog would write `?page=` into the page behind it, two lists on one screen would share it). A first proposal kept Pessoas in the URL through two sources feeding a controlled hook (`useUrlListQuery`, `useLocalListQuery`); the owner turned it down as extra for now and asked for the hook alone. A request is the state object itself, told apart by reference (every change makes a new one), so the text keys, the reset of the answer during render and the retry counter left with the URL, and a filter can be any data; a page past the end, which now only comes from a total that shrank, moves to the last page during render. In the review that followed, the owner also had Pessoas drop the effect that copied the search term into its uncontrolled search field and the field's `defaultValue`: only the page's own handlers change it now (`ClientListFilters.search`, named `q` while it followed the URL parameter), and "Limpar busca" empties the field itself (which also ended a Base UI warning about a default value changing). Accepted by the owner on 2026-10-20: Pessoas no longer survives a reload or a link, Back and Forward no longer walk its pages, and coming back from `/pessoas/:id` or `/pessoas/nova` (siblings of the list) starts over. The hook's tests run without a Router. [ADR 0072](../../../docs/adr/0072-admin-frontend-paged-list-state-in-the-hook.md). | +| `list-section` is always a table: `columns` is required, and the `renderItem` mode (a list with no header), `list-section-list.tsx` and the strip skeleton are gone | Asked for by the owner on 2026-10-20, reading `ListSectionSkeleton`: its `if` chose between the table and the strip, and a list section always has a header. No screen used `renderItem` (Pessoas and Etiquetas pass `columns`; §6 had kept it for a feed), only the widget tests, so the skeleton and the ready state each lose a branch and the props lose the either/or type. The tests that used `renderItem` pass one column, and the ones that repeated a case in list and in table mode are one each. A listing without natural columns, if one comes, gets its own widget. | +| `usePaginatedList` asks the backend only when told to: every action (`applyFilters`, which was `setFilters`, a page, a size, the retry) goes through one `request(next)` that aborts the request in flight and sends at once; the first page comes from an effect with no dependencies; a page past the end is corrected where the answer arrives | Asked for by the owner on 2026-10-20, from experience with effect-driven fetching in a previous job (refetch on unstable dependencies or unrelated state, double requests, an older answer over a newer one): the hook avoided them only through an invariant a later edit could break. Researched before choosing; React Router fetchers (they revalidate on their own after actions), a promise read with `use()` under Suspense (no effect, but the skeleton and the footer's last total would need restructuring), TanStack Query or SWR (reactive by default, and ADR 0035), an external store (more plumbing than one list) and `useActionState` (it queues, and does not abort) were turned down. The tests pin the rule; the one that asks for the request inside `act`, before React renders, fails against the hook with the effect. The answer is no longer tagged with its query: the abort keeps only the latest. Etiquetas, which does not page, still fetches from an effect. [ADR 0073](../../../docs/adr/0073-admin-frontend-paged-list-asks-only-when-told.md). | +| Review pass on the paged list: `itemLabel` is a prop of `PaginatedListSection`, not part of `pagination`; the widget's barrel is an `index.ts`; the pager's math is in `widgets/pagination`; `shared/format/count.ts` formats the summary; the 560 px breakpoint is a named container size | Asked for by the owner on 2026-10-20, after a review for unused code, complexity and maintainability. **(1)** The hook returned `pagination` without the noun and the page put it back (`{ ...pagination, itemLabel }`), three types for one object. The noun is copy, like `emptyCopy` and `errorTitle`, so it is a prop of its own and the hook's `pagination` is the section's prop as it is (`PaginatedListPagination` is gone). **(2)** `index.tsx` exported the component and the hook, a new `react-refresh/only-export-components` warning; the component is in `paginated-list-section.tsx` and the barrel is a `.ts`, which the rule does not scan. **(3)** `getPageWindow` and the offered sizes had one consumer, the pager, so they left `shared/pagination`, which keeps the first page, the default size, `getLastPage` and the types. The visible range is two lines in `PaginationSummary`, whose tests already pinned it. The window's constants now derive from one, `SIBLINGS`, because the slot count and the edge run were never independent. **(4)** The `Intl` formatters left the summary for `shared/format/count.ts` (`formatNumber`, `formatCount`, `CountNoun`), where the pt-BR copy skill puts formatters. **(5)** `@min-[560px]:` appeared 22 times in four files and the skeleton copied the pager's row: it is `--container-pagination` in `globals.css` (`@pagination:`, still 560 px), and the row is `PAGES_ROW` in `pagination-layout.tsx`. Also: `useClientListPage` passes `clientsRepository.list` and a typed `ClientListFilters` constant, and returns `searchTerm` (was `query`); the barrel exports nothing imports are gone; `pending` and `footerSkeleton` moved to `src/test/`. The page tests that repeat the hook's stay for now. | 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. @@ -473,23 +508,22 @@ 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 | +| A shared request-cancellation layer (in the facade or a common hook) | Cancellation stays with the hook that owns the request: `useTagListPage` and `usePaginatedList` each hold their own `AbortController` (ADR 0033's scoped exception, ADR 0071); nothing else 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 `ClientListFilters`, which `clientsRepository.list` reads, and the `initialFilters` of `usePaginatedList`, on the `shared/ui/select` that the paging footer brought ([ADR 0070](../../../docs/adr/0070-admin-frontend-list-pagination.md)) | +| `widgets/search-form`: the `role="search"` form of the list screens | `TagListPage` and `ClientListPage` repeat about fifteen lines of markup; the state-and-abort logic of a **paged** list moved into `usePaginatedList` ([ADR 0071](../../../docs/adr/0071-admin-frontend-paged-list-hook.md)), and Tags, which does not page, keeps its own | A third list screen (Serviços is the next candidate) needs the same search box: extract the form, then move Tags and Pessoas onto 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..5e4abd1c 100644 --- a/apps/admin-frontend/e2e/clients.spec.ts +++ b/apps/admin-frontend/e2e/clients.spec.ts @@ -148,24 +148,6 @@ test.describe('People list', () => { } }); - test('keeps the search in the URL, so it survives a reload', async ({ page }) => { - const person = { - name: uniqueName('Recarga E2E'), - email: `recarga.${Date.now()}@example.com`, - cpf: validCpf(), - }; - await registerPerson(page, person); - - await searchFor(page, person.name); - await expect(page).toHaveURL(/\/pessoas\?q=/); - await page.reload(); - - await expect(page.getByLabel('Buscar pessoas por nome, e-mail ou CPF')).toHaveValue( - person.name, - ); - await expect(page.getByRole('row', { name: new RegExp(person.name) })).toBeVisible(); - }); - test('names a search that finds nobody and clears it', async ({ page }) => { await page.getByRole('link', { name: 'Pessoas' }).click(); await expect(page).toHaveURL(/\/pessoas$/); @@ -175,7 +157,6 @@ test.describe('People list', () => { await expect(page.getByText(`Nenhuma pessoa encontrada para «${term}»`)).toBeVisible(); await page.getByRole('button', { name: 'Limpar busca' }).click(); - await expect(page).toHaveURL(/\/pessoas$/); await expect(page.getByLabel('Buscar pessoas por nome, e-mail ou CPF')).toHaveValue(''); await expect(page.getByText(/Nenhuma pessoa encontrada para/)).toHaveCount(0); }); diff --git a/apps/admin-frontend/e2e/helpers.ts b/apps/admin-frontend/e2e/helpers.ts index 77415c18..fe6a94e9 100644 --- a/apps/admin-frontend/e2e/helpers.ts +++ b/apps/admin-frontend/e2e/helpers.ts @@ -1,7 +1,17 @@ +import { readFileSync } from 'node:fs'; import type { Page } from '@playwright/test'; -const DEMO_EMAIL = 'owner@demo.local'; -const DEMO_PASSWORD = 'Passw0rd!'; +interface IdentityDevelopmentSettings { + DemoTenant: { OwnerEmail: string; OwnerPassword: string }; +} + +const IDENTITY_DEVELOPMENT_SETTINGS = new URL( + '../../../backend/services/identity-service/IdentityService.Api/appsettings.Development.json', + import.meta.url, +); +const { OwnerEmail: DEMO_EMAIL, OwnerPassword: DEMO_PASSWORD } = ( + JSON.parse(readFileSync(IDENTITY_DEVELOPMENT_SETTINGS, 'utf8')) as IdentityDevelopmentSettings +).DemoTenant; export const LOGIN_URL_RE = /localhost:5081\/Account\/Login/; export async function loginAsDemoUser(page: Page): Promise { diff --git a/apps/admin-frontend/src/app/globals.css b/apps/admin-frontend/src/app/globals.css index 9e3468ba..697b8c34 100644 --- a/apps/admin-frontend/src/app/globals.css +++ b/apps/admin-frontend/src/app/globals.css @@ -43,6 +43,8 @@ --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); + + --container-pagination: 560px; } :root { diff --git a/apps/admin-frontend/src/app/shell/AppShell.test.tsx b/apps/admin-frontend/src/app/shell/AppShell.test.tsx index d767cee1..1a06b9c3 100644 --- a/apps/admin-frontend/src/app/shell/AppShell.test.tsx +++ b/apps/admin-frontend/src/app/shell/AppShell.test.tsx @@ -109,4 +109,11 @@ describe('AppShell', () => { expect(screen.getByRole('button', { name: 'Mais' })).toBeInTheDocument(); expect(screen.queryByRole('link', { name: /Conversas/ })).not.toBeInTheDocument(); }); + + it('holds and clips absolutely positioned content, so a hidden text past the edge cannot widen the page and push the fixed bottom bar off a phone screen', () => { + stubViewport('bottom'); + const { container } = renderShell(); + + expect(container.firstElementChild).toHaveClass('relative', 'overflow-clip'); + }); }); diff --git a/apps/admin-frontend/src/app/shell/AppShell.tsx b/apps/admin-frontend/src/app/shell/AppShell.tsx index 3d42e6b4..831f7c25 100644 --- a/apps/admin-frontend/src/app/shell/AppShell.tsx +++ b/apps/admin-frontend/src/app/shell/AppShell.tsx @@ -13,7 +13,7 @@ export function AppShell() { const mainRef = useRouteScrollReset(); return ( -
+
{viewportKind !== 'bottom' && }
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..7de311a7 100644 --- a/apps/admin-frontend/src/features/clients/api/clientsRepository.ts +++ b/apps/admin-frontend/src/features/clients/api/clientsRepository.ts @@ -1,11 +1,18 @@ 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'; +import type { ClientListFilters } from '../model/clientListFilters'; -function list(search?: string, signal?: AbortSignal): Promise> { +export type ClientListQuery = ClientListFilters & Paging; + +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/model/clientListFilters.ts b/apps/admin-frontend/src/features/clients/model/clientListFilters.ts new file mode 100644 index 00000000..449212c8 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/model/clientListFilters.ts @@ -0,0 +1,3 @@ +export interface ClientListFilters { + search: string; +} 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..da6ce317 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 @@ -1,8 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; 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 { MemoryRouter, useLocation } from 'react-router'; +import { footerSkeleton } from '@/test/footerSkeleton'; +import { pending } from '@/test/pending'; 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 +15,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 +37,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() { @@ -51,15 +91,17 @@ 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)); - }); - return renderClientListPage(initialEntries); +function renderPage(clients: ClientListItem[]) { + serve(clients); + return renderClientListPage(); +} + +function location() { + return screen.getByTestId('location').textContent; +} + +function skeletonRows(container: HTMLElement) { + return container.querySelector('[aria-busy="true"]')?.querySelectorAll('tbody tr'); } describe('ClientListPage', () => { @@ -68,31 +110,26 @@ describe('ClientListPage', () => { }); 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 () => { @@ -193,30 +230,50 @@ describe('ClientListPage', () => { it('retries the same search from the failure, showing the skeleton while it reloads', async () => { const user = userEvent.setup(); + mockList.mockResolvedValueOnce(listOf(CLIENTS)); mockList.mockResolvedValueOnce({ 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 { container } = renderClientListPage(['/pessoas?q=ana']); + const retry = pending>(); + mockList.mockImplementationOnce(() => retry.promise); + const { container } = renderClientListPage(); + await screen.findByText('Ana Souza'); + await user.type(screen.getByLabelText(SEARCH_LABEL), 'ana{Enter}'); 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(); + renderPage(people(87)); + await screen.findByText('Pessoa 001'); + await user.click(await screen.findByRole('combobox', { name: PAGE_SIZE_LABEL })); + await user.click(await screen.findByRole('option', { name: '10' })); + await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(11)); + + mockList.mockResolvedValueOnce({ ok: false, error: { title: 'Sem conexão.' } }); + await user.click(screen.getByRole('button', { name: 'Ir para a página 3' })); + await user.click(await screen.findByRole('button', { name: 'Tentar novamente' })); + + expect(await screen.findByText('Pessoa 021')).toBeInTheDocument(); + expect(mockList).toHaveBeenLastCalledWith( + { search: '', page: 3, pageSize: 10 }, + expect.any(AbortSignal), + ); + }); + it('says there is nobody yet, without a search action, when the list is empty and nothing was searched', async () => { renderPage([]); @@ -237,13 +294,15 @@ describe('ClientListPage', () => { expect(screen.getByRole('button', { name: 'Limpar busca' })).toBeInTheDocument(); }); - it('clears the search, the URL and the field, and lists everyone again, from "Limpar busca"', async () => { + it('clears the search and the field, and lists everyone again, from "Limpar busca"', async () => { const user = userEvent.setup(); - renderPage(CLIENTS, ['/pessoas?q=zzz']); + renderPage(CLIENTS); + await screen.findByText('Ana Souza'); + await user.type(screen.getByLabelText(SEARCH_LABEL), 'zzz{Enter}'); + 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(screen.getByLabelText(SEARCH_LABEL)).toHaveValue(''); }); @@ -258,7 +317,7 @@ describe('ClientListPage', () => { expect(screen.getByText('Ana Souza')).toBeInTheDocument(); }); - it('narrows the list to the submitted term and puts it in the URL as ?q=', async () => { + it('narrows the list to the submitted term, leaving the address alone (ADR 0072)', async () => { const user = userEvent.setup(); renderPage(CLIENTS); await screen.findByText('Ana Souza'); @@ -267,8 +326,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'); }); it('searches from the Buscar button too', async () => { @@ -283,26 +345,28 @@ describe('ClientListPage', () => { expect(screen.getByText('Bruno Lima')).toBeInTheDocument(); }); - it('reads the initial search from the URL, pre-filling the field and filtering the first fetch', async () => { - renderPage(CLIENTS, ['/pessoas?q=bruno']); + it('starts on the first page of everyone whatever the address carries (ADR 0072)', async () => { + serve(CLIENTS); + renderClientListPage(['/pessoas?q=bruno&page=3&pageSize=10']); - expect(screen.getByLabelText(SEARCH_LABEL)).toHaveValue('bruno'); - await waitFor(() => expect(mockList).toHaveBeenCalledWith('bruno', expect.any(AbortSignal))); - expect(await screen.findByText('Bruno Lima')).toBeInTheDocument(); - expect(screen.queryByText('Ana Souza')).not.toBeInTheDocument(); + expect(screen.getByLabelText(SEARCH_LABEL)).toHaveValue(''); + expect(await screen.findByText('Ana Souza')).toBeInTheDocument(); + expect(mockList).toHaveBeenCalledTimes(1); + expect(mockList).toHaveBeenCalledWith(FIRST_PAGE_OF_EVERYONE, expect.any(AbortSignal)); }); - it('removes ?q= instead of leaving it empty when the search is submitted blank', async () => { + it('lists everyone again when the search is submitted blank', async () => { const user = userEvent.setup(); - renderPage(CLIENTS, ['/pessoas?q=bruno']); - await screen.findByText('Bruno Lima'); + renderPage(CLIENTS); + await screen.findByText('Ana Souza'); + await user.type(screen.getByLabelText(SEARCH_LABEL), 'bruno{Enter}'); + await waitFor(() => expect(screen.queryByText('Ana Souza')).not.toBeInTheDocument()); await user.clear(screen.getByLabelText(SEARCH_LABEL)); await user.keyboard('{Enter}'); - await waitFor(() => - expect(screen.getByTestId('location').textContent).toBe('location: /pessoas'), - ); + expect(await screen.findByText('Ana Souza')).toBeInTheDocument(); + expect(mockList).toHaveBeenLastCalledWith(FIRST_PAGE_OF_EVERYONE, expect.any(AbortSignal)); }); it('shows the loading skeleton while a new search is in flight, replacing the stale rows', async () => { @@ -311,13 +375,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 +384,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 +395,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(); @@ -384,35 +433,409 @@ describe('ClientListPage', () => { expect(searchInput).toHaveFocus(); }); - 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) => - Promise.resolve( - listOf( - search - ? CLIENTS.filter((client) => client.fullName.toLowerCase().includes(search)) - : CLIENTS, - ), - ), - ); - const router = createMemoryRouter([{ path: '/pessoas', element: }], { - initialEntries: ['/pessoas'], + describe('paging', () => { + 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); }); - render(); - await screen.findByText('Ana Souza'); - const searchInput = screen.getByLabelText(SEARCH_LABEL); - await user.type(searchInput, 'bruno{Enter}'); - await waitFor(() => expect(screen.queryByText('Ana Souza')).not.toBeInTheDocument()); + it('draws the footer as a skeleton, with no controls, before the first answer', async () => { + const initial = pending>(); + mockList.mockImplementationOnce(() => initial.promise); + const { container } = renderClientListPage(); - await act(() => router.navigate(-1)); + expect(footerSkeleton(container)).not.toBeNull(); + expect(screen.queryByRole('navigation', { name: 'Paginação' })).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); - expect(await screen.findByText('Ana Souza')).toBeInTheDocument(); - expect(searchInput).toHaveValue(''); + initial.resolve(listOf(people(87).slice(0, 20), { totalCount: 87 })); - await act(() => router.navigate(1)); + expect(await screen.findByRole('navigation', { name: 'Paginação' })).toBeInTheDocument(); + expect(footerSkeleton(container)).toBeNull(); + }); - await waitFor(() => expect(screen.queryByText('Ana Souza')).not.toBeInTheDocument()); - expect(searchInput).toHaveValue('bruno'); + it('draws the footer as a skeleton, not the total of the last search, while a new search 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 search = pending>(); + mockList.mockImplementationOnce(() => search.promise); + await user.type(screen.getByLabelText(SEARCH_LABEL), 'pessoa 00{Enter}'); + + await waitFor(() => expect(footerSkeleton(container)).not.toBeNull()); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.queryByRole('navigation', { name: 'Paginação' })).not.toBeInTheDocument(); + + search.resolve(listOf(everyone.slice(0, 9), { totalCount: 9 })); + + expect(await screen.findByRole('status')).toHaveTextContent('Exibindo 1–9 de 9 pessoas'); + expect(footerSkeleton(container)).toBeNull(); + }); + + it('shows 20 skeleton rows, the default page size, on the first load', () => { + mockList.mockImplementationOnce(() => pending>().promise); + + const { container } = renderClientListPage(); + + expect(skeletonRows(container)).toHaveLength(20); + }); + + it('shows as many skeleton rows as the size just picked while that size loads', async () => { + const user = userEvent.setup(); + mockList.mockResolvedValueOnce(listOf(people(87).slice(0, 20), { totalCount: 87 })); + const { container } = renderClientListPage(); + await screen.findByText('Pessoa 001'); + + mockList.mockImplementationOnce(() => pending>().promise); + await user.click(screen.getByRole('combobox', { name: PAGE_SIZE_LABEL })); + await user.click(await screen.findByRole('option', { name: '50' })); + + await waitFor(() => expect(container.querySelector('[aria-busy="true"]')).not.toBeNull()); + expect(skeletonRows(container)).toHaveLength(50); + }); + + it('goes to the next page, keeping the search and the size, and leaves the address alone', async () => { + const user = userEvent.setup(); + renderPage(people(87)); + await screen.findByText('Pessoa 001'); + await user.type(screen.getByLabelText(SEARCH_LABEL), 'pessoa{Enter}'); + await user.click(await screen.findByRole('combobox', { name: PAGE_SIZE_LABEL })); + await user.click(await screen.findByRole('option', { name: '10' })); + await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(11)); + + 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'); + 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(); + }); + + it('goes back to the page before from Anterior', async () => { + const user = userEvent.setup(); + renderPage(people(87)); + await screen.findByText('Pessoa 001'); + await user.click(screen.getByRole('button', { name: 'Próxima' })); + await screen.findByText('Pessoa 021'); + + await user.click(screen.getByRole('button', { name: 'Anterior' })); + + expect(await screen.findByText('Pessoa 001')).toBeInTheDocument(); + expect(mockList).toHaveBeenLastCalledWith(FIRST_PAGE_OF_EVERYONE, expect.any(AbortSignal)); + }); + + it('keeps the controls over the skeleton, usable 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(skeletonRows(container)).toHaveLength(20); + expect(screen.queryByText('Pessoa 001')).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 21–40 de 87 pessoas'); + expect(screen.getByRole('button', { name: 'Anterior' })).toHaveAttribute( + 'aria-disabled', + 'false', + ); + expect(screen.getByRole('button', { name: 'Próxima' })).toHaveAttribute( + 'aria-disabled', + 'false', + ); + expect(screen.getByRole('combobox', { name: PAGE_SIZE_LABEL })).toBeEnabled(); + + next.resolve(listOf(everyone.slice(20, 40), { totalCount: 87, page: 2 })); + + expect(await screen.findByText('Pessoa 021')).toBeInTheDocument(); + }); + + it('moves on to another page while one is still loading, and drops the answer that was in flight', async () => { + const user = userEvent.setup(); + const everyone = people(87); + mockList.mockResolvedValueOnce(listOf(everyone.slice(0, 20), { totalCount: 87 })); + renderClientListPage(); + await screen.findByText('Pessoa 001'); + + const second = pending>(); + let secondSignal: AbortSignal | undefined; + mockList.mockImplementationOnce((_request: ClientListQuery, signal: AbortSignal) => { + secondSignal = signal; + return second.promise; + }); + await user.click(screen.getByRole('button', { name: 'Próxima' })); + await waitFor(() => expect(mockList).toHaveBeenCalledTimes(2)); + + mockList.mockResolvedValueOnce(listOf(everyone.slice(40, 60), { totalCount: 87, page: 3 })); + await user.click(screen.getByRole('button', { name: 'Próxima' })); + + expect(await screen.findByText('Pessoa 041')).toBeInTheDocument(); + expect(mockList).toHaveBeenLastCalledWith( + { search: '', page: 3, pageSize: 20 }, + expect.any(AbortSignal), + ); + expect(secondSignal?.aborted).toBe(true); + + await act(async () => { + second.resolve(listOf(everyone.slice(20, 40), { totalCount: 87, page: 2 })); + await second.promise; + }); + + expect(screen.getByText('Pessoa 041')).toBeInTheDocument(); + expect(screen.queryByText('Pessoa 021')).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 41–60 de 87 pessoas'); + }); + + it('changes the size while a page is still loading, and drops that page', async () => { + const user = userEvent.setup(); + const everyone = people(87); + mockList.mockResolvedValueOnce(listOf(everyone.slice(0, 20), { totalCount: 87 })); + renderClientListPage(); + await screen.findByText('Pessoa 001'); + + const second = pending>(); + let secondSignal: AbortSignal | undefined; + mockList.mockImplementationOnce((_request: ClientListQuery, signal: AbortSignal) => { + secondSignal = signal; + return second.promise; + }); + await user.click(screen.getByRole('button', { name: 'Próxima' })); + await waitFor(() => expect(mockList).toHaveBeenCalledTimes(2)); + + mockList.mockResolvedValueOnce( + listOf(everyone.slice(0, 50), { totalCount: 87, page: 1, pageSize: 50 }), + ); + 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: '', page: 1, pageSize: 50 }, + expect.any(AbortSignal), + ); + expect(secondSignal?.aborted).toBe(true); + + await act(async () => { + second.resolve(listOf(everyone.slice(20, 40), { totalCount: 87, page: 2 })); + await second.promise; + }); + + expect(screen.getAllByRole('row')).toHaveLength(51); + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 1–50 de 87 pessoas'); + }); + + it('shows the skeleton again, not the rows it had, when it comes back to the page it was on while others load', 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 backOnFirst = pending>(); + mockList.mockImplementationOnce(() => new Promise(() => {})); + mockList.mockImplementationOnce(() => new Promise(() => {})); + mockList.mockImplementationOnce(() => backOnFirst.promise); + await user.click(screen.getByRole('button', { name: 'Ir para a página 2' })); + await user.click(screen.getByRole('button', { name: 'Ir para a página 3' })); + await user.click(screen.getByRole('button', { name: 'Ir para a página 1' })); + + await waitFor(() => expect(mockList).toHaveBeenCalledTimes(4)); + expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + expect(screen.queryByText('Pessoa 001')).not.toBeInTheDocument(); + + backOnFirst.resolve(listOf(everyone.slice(0, 20), { totalCount: 87 })); + + expect(await screen.findByText('Pessoa 001')).toBeInTheDocument(); + expect(container.querySelector('[aria-busy="true"]')).toBeNull(); + }); + + it('shows the skeleton again when a search comes back to the one on screen while another loads', async () => { + const user = userEvent.setup(); + mockList.mockResolvedValueOnce(listOf(CLIENTS)); + const { container } = renderClientListPage(); + await screen.findByText('Ana Souza'); + + const backOnAll = pending>(); + mockList.mockImplementationOnce(() => new Promise(() => {})); + mockList.mockImplementationOnce(() => backOnAll.promise); + await user.type(screen.getByLabelText(SEARCH_LABEL), 'bruno{Enter}'); + await waitFor(() => expect(mockList).toHaveBeenCalledTimes(2)); + await user.clear(screen.getByLabelText(SEARCH_LABEL)); + await user.keyboard('{Enter}'); + + await waitFor(() => expect(mockList).toHaveBeenCalledTimes(3)); + expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + expect(screen.queryByText('Ana Souza')).not.toBeInTheDocument(); + + backOnAll.resolve(listOf(CLIENTS)); + + expect(await screen.findByText('Ana Souza')).toBeInTheDocument(); + }); + + it('goes back to the first page when the size changes, keeping the search', async () => { + const user = userEvent.setup(); + renderPage(people(87)); + await screen.findByText('Pessoa 001'); + await user.type(screen.getByLabelText(SEARCH_LABEL), 'pessoa{Enter}'); + await user.click(await screen.findByRole('button', { name: 'Ir para a página 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(screen.getByRole('status')).toHaveTextContent('Exibindo 1–50 de 87 pessoas'); + }); + + it('starts over on the first page when a search is submitted, keeping the size', async () => { + const user = userEvent.setup(); + renderPage(people(87)); + await screen.findByText('Pessoa 001'); + await user.click(await screen.findByRole('combobox', { name: PAGE_SIZE_LABEL })); + await user.click(await screen.findByRole('option', { name: '10' })); + await user.click(await screen.findByRole('button', { name: 'Ir para a página 3' })); + 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), + ); + }); + + it('keeps the size when the search is cleared', async () => { + const user = userEvent.setup(); + renderPage(people(87)); + await screen.findByText('Pessoa 001'); + await user.click(await screen.findByRole('combobox', { name: PAGE_SIZE_LABEL })); + await user.click(await screen.findByRole('option', { name: '50' })); + await screen.findByText('Pessoa 050'); + await user.type(screen.getByLabelText(SEARCH_LABEL), 'zzz{Enter}'); + + await user.click(await screen.findByRole('button', { name: 'Limpar busca' })); + + expect(await screen.findByText('Pessoa 050')).toBeInTheDocument(); + expect(mockList).toHaveBeenLastCalledWith( + { search: '', page: 1, pageSize: 50 }, + expect.any(AbortSignal), + ); + }); + + it('has no controls when a search finds nobody', async () => { + const user = userEvent.setup(); + renderPage(people(87)); + await screen.findByText('Pessoa 001'); + + await user.type(screen.getByLabelText(SEARCH_LABEL), 'zzz{Enter}'); + + 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(); + }); + + describe('a page that no longer exists', () => { + it('moves to the last page that exists when the total shrinks under the page on screen', async () => { + const user = userEvent.setup(); + const remaining = people(25); + mockList.mockResolvedValueOnce(listOf(people(87).slice(0, 20), { totalCount: 87 })); + mockList.mockResolvedValueOnce(listOf([], { totalCount: 25, page: 5 })); + mockList.mockResolvedValueOnce(listOf(remaining.slice(20), { totalCount: 25, page: 2 })); + renderClientListPage(); + await screen.findByText('Pessoa 001'); + + await user.click(screen.getByRole('button', { name: 'Ir para a página 5' })); + + expect(await screen.findByText('Pessoa 021')).toBeInTheDocument(); + expect(mockList).toHaveBeenNthCalledWith( + 2, + { search: '', page: 5, pageSize: 20 }, + expect.any(AbortSignal), + ); + expect(mockList).toHaveBeenNthCalledWith( + 3, + { search: '', page: 2, pageSize: 20 }, + expect.any(AbortSignal), + ); + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 21–25 de 25 pessoas'); + }); + + it('shows the skeleton, not an empty list, while it moves to the last page that exists', async () => { + const user = userEvent.setup(); + const lastPage = pending>(); + mockList.mockResolvedValueOnce(listOf(people(87).slice(0, 20), { totalCount: 87 })); + mockList.mockResolvedValueOnce(listOf([], { totalCount: 25, page: 5 })); + mockList.mockImplementationOnce(() => lastPage.promise); + const { container } = renderClientListPage(); + await screen.findByText('Pessoa 001'); + + await user.click(screen.getByRole('button', { name: 'Ir para a página 5' })); + + await waitFor(() => expect(mockList).toHaveBeenCalledTimes(3)); + expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + expect(screen.queryByText('Nenhuma pessoa ainda')).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 21–25 de 25 pessoas'); + expect(screen.getByRole('combobox', { name: PAGE_SIZE_LABEL })).toBeEnabled(); + + lastPage.resolve(listOf(people(25).slice(20), { totalCount: 25, page: 2 })); + + expect(await screen.findByText('Pessoa 021')).toBeInTheDocument(); + }); + + it('goes to the first page, and says there is nobody, when everyone is gone', async () => { + const user = userEvent.setup(); + mockList.mockResolvedValueOnce(listOf(people(87).slice(0, 20), { totalCount: 87 })); + mockList.mockResolvedValueOnce(listOf([], { totalCount: 0, page: 3 })); + mockList.mockResolvedValueOnce(listOf([], { totalCount: 0 })); + renderClientListPage(); + await screen.findByText('Pessoa 001'); + + await user.click(screen.getByRole('button', { name: 'Ir para a página 3' })); + + expect(await screen.findByText('Nenhuma pessoa ainda')).toBeInTheDocument(); + expect(mockList).toHaveBeenLastCalledWith(FIRST_PAGE_OF_EVERYONE, expect.any(AbortSignal)); + }); + }); }); }); 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..2388f5a4 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 @@ -1,7 +1,9 @@ import { PlusIcon } from 'lucide-react'; import { Link } from 'react-router'; +import type { CountNoun } from '@/shared/format/count'; import { buttonVariants } from '@/shared/ui/button'; -import { ListSection, type ListSectionEmptyCopy } from '@/widgets/list-section'; +import type { ListSectionEmptyCopy } from '@/widgets/list-section'; +import { PaginatedListSection } from '@/widgets/paginated-list-section'; import { NEW_CLIENT_PATH } from '../../clientsPaths'; import { clientColumns } from './components/clientColumns'; import { ClientListSearch } from './components/ClientListSearch'; @@ -14,17 +16,11 @@ const EMPTY_COPY: ListSectionEmptyCopy = { searchDescription: 'Confira a grafia ou busque pelo e-mail ou pelo CPF.', }; +const ITEM_LABEL: CountNoun = { singular: 'pessoa', plural: 'pessoas' }; + export function ClientListPage() { - const { - clients, - status, - failure, - query, - searchInputRef, - onSearchSubmit, - onClearSearch, - onRetry, - } = useClientListPage(); + const { list, pagination, searchTerm, searchInputRef, onSearchSubmit, onClearSearch } = + useClientListPage(); return (
@@ -36,22 +32,18 @@ export function ClientListPage() {
- + - client.id} aria-label="Pessoas" columns={clientColumns()} emptyCopy={EMPTY_COPY} - search={{ term: query, onClear: onClearSearch }} + search={{ term: searchTerm, onClear: onClearSearch }} errorTitle="Não foi possível carregar as pessoas." - failure={failure && { ...failure, onRetry }} + pagination={pagination} + itemLabel={ITEM_LABEL} />
); diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/ClientListSearch.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/ClientListSearch.tsx index 2f442236..3d85b27e 100644 --- a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/ClientListSearch.tsx +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/ClientListSearch.tsx @@ -8,12 +8,9 @@ import { import { CLIENT_SEARCH_MAX_LENGTH } from '../../../../model/client'; import type { UseClientListPageResult } from '../useClientListPage.types'; -type ClientListSearchProps = Pick< - UseClientListPageResult, - 'query' | 'searchInputRef' | 'onSearchSubmit' ->; +type ClientListSearchProps = Pick; -export function ClientListSearch({ query, searchInputRef, onSearchSubmit }: ClientListSearchProps) { +export function ClientListSearch({ searchInputRef, onSearchSubmit }: ClientListSearchProps) { return (
@@ -22,8 +19,7 @@ export function ClientListSearch({ query, searchInputRef, onSearchSubmit }: Clie [] { { key: 'fullName', header: 'Nome', + skeleton: , cell: (client) => ( [] { { key: 'phone', header: 'Telefone', + skeleton: , cell: (client) => , }, { key: 'email', header: 'E-mail', + skeleton: , cell: (client) => , }, { key: 'cpf', header: 'CPF', + skeleton: , cell: (client) => ( [] { { key: 'status', header: 'Situação', + skeleton: , cell: (client) => ( - + {CLIENT_STATUS_LABELS[client.status]} ), 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..aa7cadc9 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,85 +1,34 @@ -import { useCallback, useEffect, useRef, useState, type SubmitEvent } from 'react'; -import { useSearchParams } from 'react-router'; -import { extractErrorMessage, type ApiResult } from '@/shared/api/servicesFacade'; -import { ListSectionStatus } from '@/widgets/list-section'; +import { useRef, type SubmitEvent } from 'react'; +import { usePaginatedList } from '@/widgets/paginated-list-section'; import { clientsRepository } from '../../../api/clientsRepository'; -import type { ClientList } from '../../../model/clientList'; -import type { - ClientListState, - LoadResult, - UseClientListPageResult, -} from './useClientListPage.types'; +import type { ClientListFilters } from '../../../model/clientListFilters'; +import type { UseClientListPageResult } from './useClientListPage.types'; + +const INITIAL_FILTERS: ClientListFilters = { search: '' }; export function useClientListPage(): UseClientListPageResult { - const [searchParams, setSearchParams] = useSearchParams(); - const query = searchParams.get('q') ?? ''; - const [loaded, setLoaded] = useState(null); + const { filters, applyFilters, list, pagination } = usePaginatedList({ + initialFilters: INITIAL_FILTERS, + fetchPage: clientsRepository.list, + }); const searchInputRef = useRef(null); - const abortControllerRef = useRef(null); - - const fetchClients = useCallback((forQuery: string) => { - abortControllerRef.current?.abort(); - const controller = new AbortController(); - abortControllerRef.current = controller; - - void clientsRepository.list(forQuery || undefined, controller.signal).then((apiResult) => { - if (controller.signal.aborted) return; - setLoaded({ query: forQuery, apiResult }); - }); - }, []); - - useEffect(() => { - fetchClients(query); - return () => { - abortControllerRef.current?.abort(); - }; - }, [query, fetchClients]); - - useEffect(() => { - const input = searchInputRef.current; - if (input && input.value !== query) input.value = query; - }, [query]); function onSearchSubmit(event: SubmitEvent) { event.preventDefault(); - const nextQuery = searchInputRef.current?.value ?? ''; - setSearchParams(nextQuery ? { q: nextQuery } : {}); + applyFilters({ search: searchInputRef.current?.value ?? '' }); } function onClearSearch() { - setSearchParams({}); - } - - function onRetry() { - setLoaded(null); - fetchClients(query); - } - - function toListState(apiResult: ApiResult | null): ClientListState { - if (apiResult === null) { - return { status: ListSectionStatus.Loading, clients: [], failure: null }; - } - - if (!apiResult.ok) { - const { error } = apiResult; - return { - status: ListSectionStatus.Error, - clients: [], - failure: { message: extractErrorMessage(error), code: error.code ?? undefined }, - }; - } - - return { status: ListSectionStatus.Ready, clients: apiResult.data.items, failure: null }; + if (searchInputRef.current) searchInputRef.current.value = ''; + applyFilters({ search: '' }); } - const current = loaded?.query === query ? loaded.apiResult : null; - return { - ...toListState(current), - query, + list, + pagination, + searchTerm: filters.search, searchInputRef, onSearchSubmit, onClearSearch, - onRetry, }; } 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..14bee82a 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,28 +1,15 @@ import type { RefObject, SubmitEvent } from 'react'; -import type { ApiResult } from '@/shared/api/servicesFacade'; -import type { ListSectionStatus } from '@/widgets/list-section'; -import type { ClientList, ClientListItem } from '../../../model/clientList'; - -export interface ClientListFailure { - message: string; - code: string | undefined; -} - -export interface ClientListState { - status: ListSectionStatus; - clients: ClientListItem[]; - failure: ClientListFailure | null; -} - -export interface UseClientListPageResult extends ClientListState { - query: string; +import type { + PaginatedListSectionPaging, + PaginatedListState, +} from '@/widgets/paginated-list-section'; +import type { ClientListItem } from '../../../model/clientList'; + +export interface UseClientListPageResult { + list: PaginatedListState; + pagination: PaginatedListSectionPaging; + searchTerm: string; searchInputRef: RefObject; onSearchSubmit: (event: SubmitEvent) => void; onClearSearch: () => void; - onRetry: () => void; -} - -export interface LoadResult { - query: string; - apiResult: ApiResult; } diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/tagColumns.tsx b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/tagColumns.tsx index bdd437dd..b02f01a2 100644 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/tagColumns.tsx +++ b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/tagColumns.tsx @@ -1,5 +1,6 @@ import type { CSSProperties } from 'react'; import { OptionalText } from '@/shared/ui/optional-text'; +import { Skeleton } from '@/shared/ui/skeleton'; import type { ListSectionColumn } from '@/widgets/list-section'; import { TagDeleteLink } from './TagDeleteLink'; import { TagEditLink } from './TagEditLink'; @@ -10,6 +11,7 @@ export function tagColumns(): ListSectionColumn[] { { key: 'name', header: 'Nome', + skeleton: , cell: (tag) => ( {tag.name} @@ -19,6 +21,7 @@ export function tagColumns(): ListSectionColumn[] { { key: 'description', header: 'Descrição', + skeleton: , cell: (tag) => ( [] { key: 'actions', header: 'Ações', align: 'end', + skeleton: ( +
+ + +
+ ), cell: (tag) => (
diff --git a/apps/admin-frontend/src/shared/format/count.test.ts b/apps/admin-frontend/src/shared/format/count.test.ts new file mode 100644 index 00000000..0566bec0 --- /dev/null +++ b/apps/admin-frontend/src/shared/format/count.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { formatCount, formatNumber } from './count'; + +const PERSON = { singular: 'pessoa', plural: 'pessoas' }; + +describe('formatNumber', () => { + it.each([ + [7, '7'], + [1234, '1.234'], + [1234567, '1.234.567'], + ] as const)('writes %i as %j', (value, expected) => { + expect(formatNumber(value)).toBe(expected); + }); +}); + +describe('formatCount', () => { + it.each([ + [1, '1 pessoa'], + [2, '2 pessoas'], + [87, '87 pessoas'], + [1234, '1.234 pessoas'], + ] as const)('writes %i people as %j', (count, expected) => { + expect(formatCount(count, PERSON)).toBe(expected); + }); +}); diff --git a/apps/admin-frontend/src/shared/format/count.ts b/apps/admin-frontend/src/shared/format/count.ts new file mode 100644 index 00000000..df808f10 --- /dev/null +++ b/apps/admin-frontend/src/shared/format/count.ts @@ -0,0 +1,17 @@ +const LOCALE = 'pt-BR'; +const numberFormat = new Intl.NumberFormat(LOCALE); +const pluralRules = new Intl.PluralRules(LOCALE); + +export interface CountNoun { + singular: string; + plural: string; +} + +export function formatNumber(value: number): string { + return numberFormat.format(value); +} + +export function formatCount(count: number, noun: CountNoun): string { + const word = pluralRules.select(count) === 'one' ? noun.singular : noun.plural; + return `${formatNumber(count)} ${word}`; +} 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..901fa072 --- /dev/null +++ b/apps/admin-frontend/src/shared/pagination/paging.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { getLastPage } from './paging'; + +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); + }); +}); 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..2b4ddc02 --- /dev/null +++ b/apps/admin-frontend/src/shared/pagination/paging.ts @@ -0,0 +1,18 @@ +export const FIRST_PAGE = 1; +export const DEFAULT_PAGE_SIZE = 20; + +export interface Paging { + page: number; + pageSize: number; +} + +export interface PagedResult { + items: T[]; + totalCount: number; + page: number; + pageSize: number; +} + +export function getLastPage(totalCount: number, pageSize: number): number { + return Math.max(FIRST_PAGE, Math.ceil(totalCount / pageSize)); +} 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..670ae835 --- /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/test/footerSkeleton.ts b/apps/admin-frontend/src/test/footerSkeleton.ts new file mode 100644 index 00000000..f1c2e9e5 --- /dev/null +++ b/apps/admin-frontend/src/test/footerSkeleton.ts @@ -0,0 +1,3 @@ +export function footerSkeleton(container: HTMLElement) { + return container.querySelector('[data-slot="pagination-skeleton"]'); +} diff --git a/apps/admin-frontend/src/test/pending.ts b/apps/admin-frontend/src/test/pending.ts new file mode 100644 index 00000000..d6330224 --- /dev/null +++ b/apps/admin-frontend/src/test/pending.ts @@ -0,0 +1,7 @@ +export function pending() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} diff --git a/apps/admin-frontend/src/widgets/list-section/components/list-section-list.tsx b/apps/admin-frontend/src/widgets/list-section/components/list-section-list.tsx deleted file mode 100644 index 4f41118a..00000000 --- a/apps/admin-frontend/src/widgets/list-section/components/list-section-list.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { ListSectionListProps } from '../list-section.types'; - -function ListSectionList({ items, renderItem, getKey, ariaLabel }: ListSectionListProps) { - return ( -
    - {items.map((item) => ( -
  • - {renderItem(item)} -
  • - ))} -
- ); -} - -export { ListSectionList }; diff --git a/apps/admin-frontend/src/widgets/list-section/components/list-section-ready.tsx b/apps/admin-frontend/src/widgets/list-section/components/list-section-ready.tsx index 646b9108..c466eb93 100644 --- a/apps/admin-frontend/src/widgets/list-section/components/list-section-ready.tsx +++ b/apps/admin-frontend/src/widgets/list-section/components/list-section-ready.tsx @@ -2,12 +2,11 @@ import type { ListSectionReadyProps } from '../list-section.types'; import { ListSectionEmpty } from './list-section-empty'; import { ListSectionTable } from './list-section-table'; -import { ListSectionList } from './list-section-list'; function ListSectionReady({ items, + columns, getKey, - renderMode, ariaLabel, emptyCopy, search, @@ -16,25 +15,7 @@ function ListSectionReady({ return ; } - if (renderMode.columns) { - return ( - - ); - } - - return ( - - ); + return ; } export { ListSectionReady }; diff --git a/apps/admin-frontend/src/widgets/list-section/components/list-section-skeleton.tsx b/apps/admin-frontend/src/widgets/list-section/components/list-section-skeleton.tsx index b4250eb3..fbf158a0 100644 --- a/apps/admin-frontend/src/widgets/list-section/components/list-section-skeleton.tsx +++ b/apps/admin-frontend/src/widgets/list-section/components/list-section-skeleton.tsx @@ -1,22 +1,23 @@ import { Skeleton } from '@/shared/ui/skeleton'; import type { ListSectionSkeletonProps } from '../list-section.types'; +import { ListSectionTable } from './list-section-table'; -function ListSectionSkeleton({ rowCount }: ListSectionSkeletonProps) { +function ListSectionSkeleton({ rowCount, columns, ariaLabel }: ListSectionSkeletonProps) { return ( -
- {Array.from({ length: rowCount }).map((_, index) => ( -
- - -
- ))} +
+
); } diff --git a/apps/admin-frontend/src/widgets/list-section/index.tsx b/apps/admin-frontend/src/widgets/list-section/index.tsx index 92326f0f..63cb3b6c 100644 --- a/apps/admin-frontend/src/widgets/list-section/index.tsx +++ b/apps/admin-frontend/src/widgets/list-section/index.tsx @@ -5,31 +5,32 @@ import { ListSectionSkeleton } from './components/list-section-skeleton'; import { ListSectionError } from './components/list-section-error'; import { ListSectionReady } from './components/list-section-ready'; -function ListSection(props: ListSectionProps) { - const { - status, - items, - getKey, - skeletonRowCount = 10, - emptyCopy, - search, - errorTitle, - failure, - 'aria-label': ariaLabel, - className, - } = props; - +function ListSection({ + columns, + status, + items, + getKey, + skeletonRowCount = 10, + emptyCopy, + search, + errorTitle, + failure, + 'aria-label': ariaLabel, + className, +}: ListSectionProps) { return (
- {status === ListSectionStatus.Loading && } + {status === ListSectionStatus.Loading && ( + + )} {status === ListSectionStatus.Error && ( )} {status === ListSectionStatus.Ready && ( { - it('renders skeleton rows and marks the region busy while loading', () => { - const { container } = render( - item.id} - renderItem={(item) => item.name} - skeletonRowCount={2} - aria-label="Itens" - />, - ); - - const busyRegion = container.querySelector('[aria-busy="true"]'); - expect(busyRegion).not.toBeNull(); - expect(busyRegion?.children).toHaveLength(2); - }); +const NAME_COLUMN: ListSectionColumn[] = [ + { key: 'name', header: 'Nome', cell: (item) => item.name }, +]; +describe('ListSection', () => { it('shows a generic error state when status is error', () => { render( item.id} - renderItem={(item) => item.name} + columns={NAME_COLUMN} aria-label="Itens" />, ); expect(screen.getByRole('alert')).toHaveTextContent('Não foi possível carregar.'); expect(screen.queryByRole('button')).not.toBeInTheDocument(); - expect(screen.queryByRole('list')).not.toBeInTheDocument(); - }); - - it('shows a generic empty state when ready with zero items, in list mode', () => { - render( - item.id} - renderItem={(item) => item.name} - aria-label="Itens" - />, - ); - - expect(screen.getByText('Nenhum item encontrado.')).toBeInTheDocument(); - expect(screen.queryByRole('list')).not.toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); }); - it('shows a generic empty state when ready with zero items, in table mode', () => { + it('shows a generic empty state when ready with zero items', () => { render( item.id} - columns={[{ key: 'name', header: 'Nome', cell: (item) => item.name }]} + columns={NAME_COLUMN} aria-label="Itens" />, ); @@ -97,7 +69,7 @@ describe('ListSection', () => { status={status} items={items} getKey={(item) => item.id} - renderItem={(item) => item.name} + columns={NAME_COLUMN} emptyCopy={props.emptyCopy} search={props.search} aria-label="Itens" @@ -150,7 +122,7 @@ describe('ListSection', () => { status="loading" items={[] as Item[]} getKey={(item) => item.id} - renderItem={(item) => item.name} + columns={NAME_COLUMN} emptyCopy={COPY} search={search} aria-label="Itens" @@ -179,7 +151,7 @@ describe('ListSection', () => { status={status} items={items} getKey={(item) => item.id} - renderItem={(item) => item.name} + columns={NAME_COLUMN} errorTitle={props.errorTitle} failure={props.failure} aria-label="Itens" @@ -231,7 +203,7 @@ describe('ListSection', () => { status="ready" items={ITEMS} getKey={(item) => item.id} - renderItem={(item) => item.name} + columns={NAME_COLUMN} failure={FAILURE} aria-label="Itens" />, @@ -240,25 +212,7 @@ describe('ListSection', () => { }); }); - it('renders one listitem per item via renderItem in list mode', () => { - render( - item.id} - renderItem={(item) => item.name} - aria-label="Itens" - />, - ); - - const list = screen.getByRole('list', { name: 'Itens' }); - const rows = within(list).getAllByRole('listitem'); - expect(rows).toHaveLength(2); - expect(rows[0]).toHaveTextContent('Primeiro'); - expect(rows[1]).toHaveTextContent('Segundo'); - }); - - it('renders a table with one column header per column, in order, in table mode', () => { + it('renders a table with one column header per column, in order, and one row per item', () => { render( { const headers = screen.getAllByRole('columnheader'); expect(headers.map((header) => header.textContent)).toEqual(['Nome', 'ID']); - expect(screen.getByRole('cell', { name: 'Primeiro' })).toBeInTheDocument(); + const rows = within(screen.getByRole('table')).getAllByRole('row').slice(1); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveTextContent('Primeiro'); + expect(rows[1]).toHaveTextContent('Segundo'); }); - it('labels the table with aria-label in table mode', () => { + it('labels the table with aria-label', () => { render( { status="ready" items={ITEMS} getKey={(item) => item.id} - renderItem={(item) => item.name} + columns={NAME_COLUMN} aria-label="Itens" className="custom-wrapper" />, @@ -363,12 +320,93 @@ describe('ListSection', () => { status="loading" items={[] as Item[]} getKey={(item) => item.id} - renderItem={(item) => item.name} + columns={NAME_COLUMN} aria-label="Itens" />, ); const busyRegion = container.querySelector('[aria-busy="true"]'); - expect(busyRegion?.children).toHaveLength(10); + expect(busyRegion?.querySelectorAll('tbody tr')).toHaveLength(10); + }); + + describe('skeleton', () => { + const COLUMNS = [ + { key: 'name', header: 'Nome', cell: (item: Item) => item.name }, + { + key: 'weight', + header: 'Peso', + align: 'end' as const, + skeleton: , + cell: (item: Item) => item.id, + }, + ]; + + function renderLoadingTable(rowCount: number) { + return render( + item.id} + columns={COLUMNS} + skeletonRowCount={rowCount} + aria-label="Itens" + />, + ); + } + + it('imitates the table while loading: the real headers and one row per skeleton row', () => { + const { container } = renderLoadingTable(3); + + const busyRegion = container.querySelector('[aria-busy="true"]'); + expect(busyRegion).not.toBeNull(); + expect( + Array.from(busyRegion?.querySelectorAll('thead th') ?? []).map( + (header) => header.textContent, + ), + ).toEqual(['Nome', 'Peso']); + expect(busyRegion?.querySelectorAll('tbody tr')).toHaveLength(3); + }); + + it('shows the skeleton each column gives, and a generic bar where it gives none', () => { + const { container } = renderLoadingTable(2); + + expect(screen.getAllByTestId('weight-skeleton')).toHaveLength(2); + const nameCells = container.querySelectorAll('tbody tr td:first-child'); + expect(nameCells).toHaveLength(2); + nameCells.forEach((cell) => + expect(cell.querySelector('[data-slot="skeleton"]')).not.toBeNull(), + ); + }); + + it('keeps a right-aligned column right-aligned', () => { + const { container } = renderLoadingTable(1); + + expect(container.querySelectorAll('thead th')[1]).toHaveClass('text-right'); + expect(container.querySelector('tbody tr td:last-child > div')).toHaveClass('justify-end'); + }); + + it('hides the placeholder table from assistive tech and from the pointer, keeping the busy region', () => { + const { container } = renderLoadingTable(2); + + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + expect(container.querySelector('[aria-busy="true"][aria-live="polite"]')).not.toBeNull(); + expect(container.querySelector('[aria-hidden="true"]')).toHaveClass('pointer-events-none'); + }); + + it('is the real table once the list is ready, with no placeholder left', () => { + const { container } = render( + item.id} + columns={COLUMNS} + aria-label="Itens" + />, + ); + + expect(container.querySelector('[aria-busy="true"]')).toBeNull(); + expect(screen.getByRole('table', { name: 'Itens' })).toBeInTheDocument(); + expect(screen.queryByTestId('weight-skeleton')).not.toBeInTheDocument(); + }); }); }); diff --git a/apps/admin-frontend/src/widgets/list-section/list-section.types.ts b/apps/admin-frontend/src/widgets/list-section/list-section.types.ts index 6565625d..661f7019 100644 --- a/apps/admin-frontend/src/widgets/list-section/list-section.types.ts +++ b/apps/admin-frontend/src/widgets/list-section/list-section.types.ts @@ -12,13 +12,10 @@ interface ListSectionColumn { key: string; header: string; align?: 'end' | undefined; + skeleton?: ReactNode; cell: (item: T) => ReactNode; } -type ListSectionRenderMode = - | { renderItem: (item: T) => ReactNode; columns?: never } - | { columns: ListSectionColumn[]; renderItem?: never }; - interface ListSectionEmptyCopy { title?: string | undefined; description?: string | undefined; @@ -37,7 +34,8 @@ interface ListSectionFailure { onRetry?: (() => void) | undefined; } -type ListSectionProps = ListSectionRenderMode & { +interface ListSectionProps { + columns: ListSectionColumn[]; status: ListSectionStatus; items: readonly T[]; getKey: (item: T) => string; @@ -48,20 +46,22 @@ type ListSectionProps = ListSectionRenderMode & { failure?: ListSectionFailure | null | undefined; 'aria-label': string; className?: string | undefined; -}; +} -interface ListSectionSkeletonProps { +interface ListSectionSkeletonProps { rowCount: number; + columns: ListSectionColumn[]; + ariaLabel: string; } -interface ListSectionItemsProps { +interface ListSectionTableProps { items: readonly T[]; + columns: ListSectionColumn[]; getKey: (item: T) => string; ariaLabel: string; } -interface ListSectionReadyProps extends ListSectionItemsProps { - renderMode: ListSectionRenderMode; +interface ListSectionReadyProps extends ListSectionTableProps { emptyCopy?: ListSectionEmptyCopy | undefined; search?: ListSectionSearch | undefined; } @@ -76,14 +76,6 @@ interface ListSectionErrorProps { failure?: ListSectionFailure | null | undefined; } -interface ListSectionTableProps extends ListSectionItemsProps { - columns: ListSectionColumn[]; -} - -interface ListSectionListProps extends ListSectionItemsProps { - renderItem: (item: T) => ReactNode; -} - export type { ListSectionColumn, ListSectionEmptyCopy, @@ -92,10 +84,7 @@ export type { ListSectionFailure, ListSectionSearch, ListSectionProps, - ListSectionRenderMode, ListSectionSkeletonProps, - ListSectionItemsProps, ListSectionReadyProps, ListSectionTableProps, - ListSectionListProps, }; diff --git a/apps/admin-frontend/src/widgets/paginated-list-section/components/paginated-list-section-footer.tsx b/apps/admin-frontend/src/widgets/paginated-list-section/components/paginated-list-section-footer.tsx new file mode 100644 index 00000000..de477e65 --- /dev/null +++ b/apps/admin-frontend/src/widgets/paginated-list-section/components/paginated-list-section-footer.tsx @@ -0,0 +1,20 @@ +import { ListSectionStatus } from '@/widgets/list-section'; +import { Pagination, PaginationSkeleton } from '@/widgets/pagination'; +import type { PaginatedListSectionFooterProps } from '../paginated-list-section.types'; + +function PaginatedListSectionFooter({ + status, + pagination, + itemLabel, +}: PaginatedListSectionFooterProps) { + const { totalCount, ...paging } = pagination; + + if (status === ListSectionStatus.Error) return null; + if (totalCount !== null) { + return ; + } + if (status === ListSectionStatus.Loading) return ; + return null; +} + +export { PaginatedListSectionFooter }; diff --git a/apps/admin-frontend/src/widgets/paginated-list-section/index.ts b/apps/admin-frontend/src/widgets/paginated-list-section/index.ts new file mode 100644 index 00000000..f09bdd1d --- /dev/null +++ b/apps/admin-frontend/src/widgets/paginated-list-section/index.ts @@ -0,0 +1,6 @@ +export { PaginatedListSection } from './paginated-list-section'; +export { usePaginatedList } from './use-paginated-list'; +export type { + PaginatedListSectionPaging, + PaginatedListState, +} from './paginated-list-section.types'; diff --git a/apps/admin-frontend/src/widgets/paginated-list-section/paginated-list-section.test.tsx b/apps/admin-frontend/src/widgets/paginated-list-section/paginated-list-section.test.tsx new file mode 100644 index 00000000..2f836b74 --- /dev/null +++ b/apps/admin-frontend/src/widgets/paginated-list-section/paginated-list-section.test.tsx @@ -0,0 +1,247 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { footerSkeleton } from '@/test/footerSkeleton'; +import type { ListSectionColumn } from '@/widgets/list-section'; +import { PaginatedListSection, type PaginatedListSectionPaging } from './index'; + +interface Item { + id: string; + name: string; +} + +const ITEMS: Item[] = [ + { id: '1', name: 'Primeiro' }, + { id: '2', name: 'Segundo' }, +]; + +const NAME_COLUMN: ListSectionColumn[] = [ + { key: 'name', header: 'Nome', cell: (item) => item.name }, +]; + +const ITEM_LABEL = { singular: 'item', plural: 'itens' }; + +const PAGINATION: PaginatedListSectionPaging = { + page: 2, + pageSize: 20, + totalCount: 87, + onPageChange: vi.fn(), + onPageSizeChange: vi.fn(), +}; + +interface RenderOptions { + status?: 'ready' | 'loading' | 'error'; + items?: Item[]; + pagination?: PaginatedListSectionPaging; + className?: string; +} + +function renderPaginated({ + status = 'ready', + items = ITEMS, + pagination = PAGINATION, + className, +}: RenderOptions = {}) { + return render( + item.id} + columns={NAME_COLUMN} + pagination={pagination} + itemLabel={ITEM_LABEL} + aria-label="Itens" + className={className} + />, + ); +} + +function busyRegion(container: HTMLElement) { + return container.querySelector('[aria-busy="true"]'); +} + +describe('PaginatedListSection', () => { + describe('the list', () => { + it('renders the items it is given as the table of a ListSection', () => { + renderPaginated(); + + const table = screen.getByRole('table', { name: 'Itens' }); + expect( + within(table) + .getAllByRole('row') + .slice(1) + .map((row) => row.textContent), + ).toEqual(['Primeiro', 'Segundo']); + }); + + it('hands the empty copy and the failure to the list', async () => { + const onRetry = vi.fn(); + const { rerender } = render( + item.id} + columns={NAME_COLUMN} + emptyCopy={{ title: 'Nada por aqui' }} + pagination={{ ...PAGINATION, totalCount: 0 }} + itemLabel={ITEM_LABEL} + aria-label="Itens" + />, + ); + expect(screen.getByText('Nada por aqui')).toBeInTheDocument(); + + rerender( + item.id} + columns={NAME_COLUMN} + failure={{ message: 'Fora do ar', onRetry }} + pagination={PAGINATION} + itemLabel={ITEM_LABEL} + aria-label="Itens" + />, + ); + await userEvent.setup().click(screen.getByRole('button', { name: 'Tentar novamente' })); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it('applies className to the box around the list and the footer', () => { + const { container } = renderPaginated({ className: 'custom-box' }); + + expect(container.firstChild).toHaveClass('custom-box'); + expect(container.firstChild).toContainElement(screen.getByRole('table', { name: 'Itens' })); + expect(container.firstChild).toContainElement( + screen.getByRole('navigation', { name: 'Paginação' }), + ); + }); + }); + + describe('the footer', () => { + it('shows where the person is, below the items, once the page is ready', () => { + renderPaginated(); + + const table = screen.getByRole('table', { name: 'Itens' }); + const summary = screen.getByRole('status'); + expect(summary).toHaveTextContent('Exibindo 21–40 de 87 itens'); + expect(table.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 and usable, over the skeleton, while a page loads', async () => { + const onPageChange = vi.fn(); + const onPageSizeChange = vi.fn(); + const { container } = renderPaginated({ + status: 'loading', + items: [], + pagination: { ...PAGINATION, onPageChange, onPageSizeChange }, + }); + const user = userEvent.setup(); + + expect(busyRegion(container)).not.toBeNull(); + expect(screen.getByRole('status')).toHaveTextContent('Exibindo 21–40 de 87 itens'); + expect(screen.getByRole('button', { name: 'Próxima' })).toHaveAttribute( + 'aria-disabled', + 'false', + ); + expect(screen.getByRole('combobox', { name: 'Itens por página' })).toBeEnabled(); + + 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('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', () => { + const { container } = renderPaginated({ status: 'error', items: [] }); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(footerSkeleton(container)).toBeNull(); + 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('draws a skeleton of the footer, with no controls, while the total is still unknown', () => { + const { container } = renderPaginated({ + status: 'loading', + items: [], + pagination: { ...PAGINATION, page: 1, totalCount: null }, + }); + + expect(screen.queryByRole('navigation', { name: 'Paginação' })).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox', { name: 'Itens por página' })).not.toBeInTheDocument(); + expect(footerSkeleton(container)).toHaveAttribute('aria-hidden', 'true'); + }); + + it('draws the footer, not its skeleton, over a loading page whose total is known', () => { + const { container } = renderPaginated({ status: 'loading', items: [] }); + + expect(screen.getByRole('navigation', { name: 'Paginação' })).toBeInTheDocument(); + expect(footerSkeleton(container)).toBeNull(); + }); + + it('draws nothing under a ready list that has no total', () => { + const { container } = renderPaginated({ pagination: { ...PAGINATION, totalCount: null } }); + + expect(screen.queryByRole('navigation', { name: 'Paginação' })).not.toBeInTheDocument(); + expect(footerSkeleton(container)).toBeNull(); + }); + }); + + describe('the skeleton', () => { + it.each([10, 20, 50, 100])('has as many rows as the page size, %i', (pageSize) => { + const { container } = renderPaginated({ + status: 'loading', + items: [], + pagination: { ...PAGINATION, page: 1, pageSize }, + }); + + expect(busyRegion(container)?.querySelectorAll('tbody tr')).toHaveLength(pageSize); + }); + + it('has them before the total is known, when there is no footer yet', () => { + const { container } = renderPaginated({ + status: 'loading', + items: [], + pagination: { ...PAGINATION, page: 1, pageSize: 50, totalCount: null }, + }); + + expect(busyRegion(container)?.querySelectorAll('tbody tr')).toHaveLength(50); + }); + }); +}); diff --git a/apps/admin-frontend/src/widgets/paginated-list-section/paginated-list-section.tsx b/apps/admin-frontend/src/widgets/paginated-list-section/paginated-list-section.tsx new file mode 100644 index 00000000..dbccca20 --- /dev/null +++ b/apps/admin-frontend/src/widgets/paginated-list-section/paginated-list-section.tsx @@ -0,0 +1,24 @@ +import { cn } from '@/shared/lib/utils'; +import { ListSection } from '@/widgets/list-section'; +import { PaginatedListSectionFooter } from './components/paginated-list-section-footer'; +import type { PaginatedListSectionProps } from './paginated-list-section.types'; + +function PaginatedListSection({ + pagination, + itemLabel, + className, + ...list +}: PaginatedListSectionProps) { + return ( +
+ + +
+ ); +} + +export { PaginatedListSection }; diff --git a/apps/admin-frontend/src/widgets/paginated-list-section/paginated-list-section.types.ts b/apps/admin-frontend/src/widgets/paginated-list-section/paginated-list-section.types.ts new file mode 100644 index 00000000..05374f38 --- /dev/null +++ b/apps/admin-frontend/src/widgets/paginated-list-section/paginated-list-section.types.ts @@ -0,0 +1,62 @@ +import type { ApiResult } from '@/shared/api/servicesFacade'; +import type { CountNoun } from '@/shared/format/count'; +import type { PagedResult, Paging } from '@/shared/pagination/paging'; +import type { + ListSectionFailure, + ListSectionProps, + ListSectionStatus, +} from '@/widgets/list-section'; +import type { PaginationProps } from '@/widgets/pagination'; + +type PaginatedListSectionPaging = Omit & { + totalCount: number | null; +}; + +type PaginatedListSectionProps = Omit, 'skeletonRowCount'> & { + pagination: PaginatedListSectionPaging; + itemLabel: CountNoun; +}; + +interface PaginatedListQuery extends Paging { + filters: F; +} + +interface PaginatedListKnownTotal { + filters: F; + totalCount: number; +} + +interface PaginatedListSectionFooterProps { + status: ListSectionStatus; + pagination: PaginatedListSectionPaging; + itemLabel: CountNoun; +} + +interface UsePaginatedListOptions { + initialFilters: F; + fetchPage: (query: F & Paging, signal: AbortSignal) => Promise>>; +} + +interface PaginatedListState { + status: ListSectionStatus; + items: readonly T[]; + failure: ListSectionFailure | null; +} + +interface UsePaginatedListResult { + filters: F; + applyFilters: (patch: Partial) => void; + list: PaginatedListState; + pagination: PaginatedListSectionPaging; +} + +export type { + PaginatedListKnownTotal, + PaginatedListQuery, + PaginatedListSectionFooterProps, + PaginatedListSectionPaging, + PaginatedListSectionProps, + PaginatedListState, + UsePaginatedListOptions, + UsePaginatedListResult, +}; diff --git a/apps/admin-frontend/src/widgets/paginated-list-section/use-paginated-list.test.tsx b/apps/admin-frontend/src/widgets/paginated-list-section/use-paginated-list.test.tsx new file mode 100644 index 00000000..97dcc414 --- /dev/null +++ b/apps/admin-frontend/src/widgets/paginated-list-section/use-paginated-list.test.tsx @@ -0,0 +1,241 @@ +import { describe, expect, it, vi } from 'vitest'; +import { act, render, renderHook, screen, waitFor } from '@testing-library/react'; +import { StrictMode } from 'react'; +import { NETWORK_PROBLEM, type ApiResult } from '@/shared/api/servicesFacade'; +import type { PagedResult, Paging } from '@/shared/pagination/paging'; +import { pending } from '@/test/pending'; +import type { UsePaginatedListOptions } from './paginated-list-section.types'; +import { usePaginatedList } from './use-paginated-list'; + +interface Item { + id: string; +} + +interface Filters { + search: string; + situation: 'open' | 'closed' | null; + pending: boolean; + tag: string[]; + minAge: number | null; +} + +type Page = ApiResult>; + +type FetchPage = (query: Filters & Paging, signal: AbortSignal) => Promise; + +const NO_FILTERS: Filters = { search: '', situation: null, pending: false, tag: [], minAge: null }; + +function pageOf(totalCount: number, items: Item[] = [{ id: '1' }]): Page { + return { ok: true, data: { items, totalCount, page: 1, pageSize: 20 } }; +} + +function signalOf(fetchPage: ReturnType>, call: number) { + return fetchPage.mock.calls[call]?.[1]; +} + +function renderList( + initialFilters: Filters = NO_FILTERS, + fetchPage = vi.fn(() => Promise.resolve(pageOf(87))), +) { + const view = renderHook(() => usePaginatedList({ initialFilters, fetchPage })); + return { ...view, fetchPage }; +} + +describe('usePaginatedList', () => { + describe('when it asks the backend (ADR 0073)', () => { + it('asks once on mount, for the initial filters on the first page at the default size, with no router around it', async () => { + const initialFilters: Filters = { + search: 'ana', + situation: 'open', + pending: true, + tag: ['a', 'b'], + minAge: 18, + }; + const { result, fetchPage } = renderList(initialFilters); + + await waitFor(() => expect(result.current.list.status).toBe('ready')); + expect(result.current.filters).toEqual(initialFilters); + expect(result.current.pagination).toMatchObject({ page: 1, pageSize: 20, totalCount: 87 }); + expect(fetchPage).toHaveBeenCalledTimes(1); + expect(fetchPage).toHaveBeenCalledWith( + { ...initialFilters, page: 1, pageSize: 20 }, + expect.any(AbortSignal), + ); + }); + + it('asks twice on mount only under StrictMode, aborting the first of the two development runs', async () => { + const fetchPage = vi.fn(() => Promise.resolve(pageOf(87))); + function List() { + const { list } = usePaginatedList({ initialFilters: NO_FILTERS, fetchPage }); + return

{list.status}

; + } + + render( + + + , + ); + + expect(await screen.findByText('ready')).toBeInTheDocument(); + expect(fetchPage).toHaveBeenCalledTimes(2); + expect(signalOf(fetchPage, 0)?.aborted).toBe(true); + expect(signalOf(fetchPage, 1)?.aborted).toBe(false); + }); + + it('does not ask again when it renders again, even with other options', async () => { + const fetchPage = vi.fn(() => Promise.resolve(pageOf(87))); + const otherFetchPage = vi.fn(() => Promise.resolve(pageOf(1))); + const { result, rerender } = renderHook( + (options: UsePaginatedListOptions) => usePaginatedList(options), + { initialProps: { initialFilters: NO_FILTERS, fetchPage } }, + ); + await waitFor(() => expect(result.current.list.status).toBe('ready')); + + rerender({ initialFilters: { ...NO_FILTERS, search: 'outro' }, fetchPage: otherFetchPage }); + rerender({ initialFilters: { ...NO_FILTERS, search: 'outro' }, fetchPage: otherFetchPage }); + + expect(fetchPage).toHaveBeenCalledTimes(1); + expect(otherFetchPage).not.toHaveBeenCalled(); + expect(result.current.filters).toEqual(NO_FILTERS); + }); + + it('asks exactly once for each action, at the moment of the action', async () => { + const { result, fetchPage } = renderList(); + await waitFor(() => expect(result.current.list.status).toBe('ready')); + + act(() => { + result.current.applyFilters({ search: 'ana' }); + expect(fetchPage).toHaveBeenCalledTimes(2); + }); + + act(() => { + result.current.pagination.onPageChange(2); + expect(fetchPage).toHaveBeenCalledTimes(3); + }); + + act(() => { + result.current.pagination.onPageSizeChange(50); + expect(fetchPage).toHaveBeenCalledTimes(4); + }); + + await waitFor(() => expect(result.current.list.status).toBe('ready')); + expect(fetchPage).toHaveBeenCalledTimes(4); + }); + + it('aborts the request in flight when another action starts, and shows only the latest answer', async () => { + const second = pending(); + const fetchPage = vi + .fn() + .mockResolvedValueOnce(pageOf(87)) + .mockImplementationOnce(() => second.promise) + .mockResolvedValueOnce(pageOf(87, [{ id: 'page-3' }])); + const { result } = renderList(NO_FILTERS, fetchPage); + await waitFor(() => expect(result.current.list.status).toBe('ready')); + + act(() => result.current.pagination.onPageChange(2)); + act(() => result.current.pagination.onPageChange(3)); + + await waitFor(() => expect(result.current.list.items).toEqual([{ id: 'page-3' }])); + expect(signalOf(fetchPage, 1)?.aborted).toBe(true); + + await act(async () => { + second.resolve(pageOf(87, [{ id: 'page-2' }])); + await second.promise; + }); + + expect(result.current.list.items).toEqual([{ id: 'page-3' }]); + expect(result.current.pagination.page).toBe(3); + }); + + it('aborts the request in flight when it unmounts', () => { + const fetchPage = vi.fn(() => new Promise(() => {})); + const { unmount } = renderList(NO_FILTERS, fetchPage); + + unmount(); + + expect(signalOf(fetchPage, 0)?.aborted).toBe(true); + }); + }); + + describe('what it asks for', () => { + it('merges a change of filters into the ones it has and starts over on the first page, keeping the size', async () => { + const { result, fetchPage } = renderList(); + await waitFor(() => expect(result.current.list.status).toBe('ready')); + act(() => result.current.pagination.onPageSizeChange(50)); + act(() => result.current.pagination.onPageChange(3)); + + act(() => result.current.applyFilters({ pending: true, tag: ['a', 'b'] })); + + expect(result.current.filters).toEqual({ ...NO_FILTERS, pending: true, tag: ['a', 'b'] }); + expect(result.current.pagination).toMatchObject({ page: 1, pageSize: 50 }); + expect(fetchPage).toHaveBeenLastCalledWith( + { ...NO_FILTERS, pending: true, tag: ['a', 'b'], page: 1, pageSize: 50 }, + expect.any(AbortSignal), + ); + }); + + it('goes back to the first page when the size changes, keeping the filters', async () => { + const { result, fetchPage } = renderList({ ...NO_FILTERS, search: 'ana' }); + await waitFor(() => expect(result.current.list.status).toBe('ready')); + act(() => result.current.pagination.onPageChange(3)); + + act(() => result.current.pagination.onPageSizeChange(10)); + + expect(result.current.pagination).toMatchObject({ page: 1, pageSize: 10 }); + expect(fetchPage).toHaveBeenLastCalledWith( + { ...NO_FILTERS, search: 'ana', page: 1, pageSize: 10 }, + expect.any(AbortSignal), + ); + }); + + it('keeps the total while another page of the same filters loads, and forgets it when they change', async () => { + const fetchPage = vi.fn(() => Promise.resolve(pageOf(87))); + const { result } = renderList(NO_FILTERS, fetchPage); + await waitFor(() => expect(result.current.pagination.totalCount).toBe(87)); + + fetchPage.mockImplementation(() => new Promise(() => {})); + act(() => result.current.pagination.onPageChange(2)); + + expect(result.current.list.status).toBe('loading'); + expect(result.current.pagination.totalCount).toBe(87); + + act(() => result.current.applyFilters({ search: 'ana' })); + + expect(result.current.pagination.totalCount).toBeNull(); + }); + + it('asks for the last page that exists when the total shrinks under the page it asked for', async () => { + const fetchPage = vi.fn(); + fetchPage + .mockResolvedValueOnce(pageOf(87)) + .mockResolvedValueOnce(pageOf(25, [])) + .mockResolvedValueOnce(pageOf(25)); + const { result } = renderList(NO_FILTERS, fetchPage); + await waitFor(() => expect(result.current.list.status).toBe('ready')); + + act(() => result.current.pagination.onPageChange(5)); + + await waitFor(() => expect(fetchPage).toHaveBeenCalledTimes(3)); + expect(fetchPage.mock.calls.map(([query]) => query.page)).toEqual([1, 5, 2]); + expect(result.current.pagination.page).toBe(2); + await waitFor(() => expect(result.current.list.items).toEqual([{ id: '1' }])); + expect(result.current.pagination.totalCount).toBe(25); + }); + + it('asks again for the same page from a failure', async () => { + const fetchPage = vi.fn(() => + Promise.resolve({ ok: false, error: NETWORK_PROBLEM }), + ); + const { result } = renderList({ ...NO_FILTERS, search: 'ana' }, fetchPage); + await waitFor(() => expect(result.current.list.status).toBe('error')); + + fetchPage.mockResolvedValue(pageOf(87)); + act(() => result.current.list.failure?.onRetry?.()); + + expect(result.current.list.status).toBe('loading'); + await waitFor(() => expect(result.current.list.status).toBe('ready')); + expect(fetchPage).toHaveBeenCalledTimes(2); + expect(fetchPage.mock.calls[1]?.[0]).toEqual(fetchPage.mock.calls[0]?.[0]); + }); + }); +}); diff --git a/apps/admin-frontend/src/widgets/paginated-list-section/use-paginated-list.ts b/apps/admin-frontend/src/widgets/paginated-list-section/use-paginated-list.ts new file mode 100644 index 00000000..02eec2ab --- /dev/null +++ b/apps/admin-frontend/src/widgets/paginated-list-section/use-paginated-list.ts @@ -0,0 +1,119 @@ +import { useEffect, useEffectEvent, useRef, useState } from 'react'; +import { extractErrorMessage, type ApiResult } from '@/shared/api/servicesFacade'; +import { + DEFAULT_PAGE_SIZE, + FIRST_PAGE, + getLastPage, + type PagedResult, +} from '@/shared/pagination/paging'; +import { ListSectionStatus } from '@/widgets/list-section'; +import type { + PaginatedListKnownTotal, + PaginatedListQuery, + PaginatedListState, + UsePaginatedListOptions, + UsePaginatedListResult, +} from './paginated-list-section.types'; + +function toListState( + result: ApiResult> | null, + onRetry: () => void, +): PaginatedListState { + if (result === null) { + return { status: ListSectionStatus.Loading, items: [], failure: null }; + } + + if (!result.ok) { + const { error } = result; + return { + status: ListSectionStatus.Error, + items: [], + failure: { message: extractErrorMessage(error), code: error.code ?? undefined, onRetry }, + }; + } + + return { status: ListSectionStatus.Ready, items: result.data.items, failure: null }; +} + +export function usePaginatedList({ + initialFilters, + fetchPage, +}: UsePaginatedListOptions): UsePaginatedListResult { + const [query, setQuery] = useState>({ + filters: initialFilters, + page: FIRST_PAGE, + pageSize: DEFAULT_PAGE_SIZE, + }); + const [result, setResult] = useState> | null>(null); + const [knownTotal, setKnownTotal] = useState | null>(null); + const inFlight = useRef(null); + const { filters, page, pageSize } = query; + + function send(next: PaginatedListQuery) { + inFlight.current?.abort(); + const controller = new AbortController(); + inFlight.current = controller; + + void fetchPage( + { ...next.filters, page: next.page, pageSize: next.pageSize }, + controller.signal, + ).then((answer) => { + if (controller.signal.aborted) return; + if (!answer.ok) { + setResult(answer); + return; + } + + setKnownTotal({ filters: next.filters, totalCount: answer.data.totalCount }); + const lastPage = getLastPage(answer.data.totalCount, next.pageSize); + if (next.page > lastPage) { + request({ ...next, page: lastPage }); + return; + } + + setResult(answer); + }); + } + + function request(next: PaginatedListQuery) { + setQuery(next); + setResult(null); + send(next); + } + + const sendFirstPage = useEffectEvent(() => { + send(query); + }); + + useEffect(() => { + sendFirstPage(); + return () => { + inFlight.current?.abort(); + }; + }, []); + + function applyFilters(patch: Partial) { + request({ filters: { ...filters, ...patch }, page: FIRST_PAGE, pageSize }); + } + + function onPageChange(nextPage: number) { + request({ ...query, page: nextPage }); + } + + function onPageSizeChange(nextPageSize: number) { + request({ ...query, page: FIRST_PAGE, pageSize: nextPageSize }); + } + + function onRetry() { + request(query); + } + + const totalCount = knownTotal?.filters === filters ? knownTotal.totalCount : null; + + return { + filters, + applyFilters, + list: toListState(result, onRetry), + pagination: { page, pageSize, totalCount, onPageChange, onPageSizeChange }, + }; +} diff --git a/apps/admin-frontend/src/widgets/pagination/components/pagination-layout.tsx b/apps/admin-frontend/src/widgets/pagination/components/pagination-layout.tsx new file mode 100644 index 00000000..43f2e430 --- /dev/null +++ b/apps/admin-frontend/src/widgets/pagination/components/pagination-layout.tsx @@ -0,0 +1,24 @@ +import type { PaginationLayoutProps } from '../pagination.types'; + +const PAGES_ROW = + 'flex items-center justify-between gap-2 @pagination:justify-center @pagination:gap-1'; + +function PaginationLayout({ summary, pages, pageSize, ...props }: PaginationLayoutProps) { + return ( +
+
+
+ {summary} +
+
+ {pages} +
+
+ {pageSize} +
+
+
+ ); +} + +export { PAGES_ROW, PaginationLayout }; 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..36e348c0 --- /dev/null +++ b/apps/admin-frontend/src/widgets/pagination/components/pagination-page-size.tsx @@ -0,0 +1,43 @@ +import { + Select, + SelectContent, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@/shared/ui/select'; +import type { PaginationPageSizeProps } from '../pagination.types'; + +const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const; + +function PaginationPageSize({ pageSize, onPageSizeChange }: 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..07b56cbb --- /dev/null +++ b/apps/admin-frontend/src/widgets/pagination/components/pagination-pages.tsx @@ -0,0 +1,64 @@ +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import { FIRST_PAGE } from '@/shared/pagination/paging'; +import { Button } from '@/shared/ui/button'; +import { getPageWindow } from '../page-window'; +import type { PaginationPagesProps } from '../pagination.types'; +import { PAGES_ROW } from './pagination-layout'; + +function PaginationPages({ page, lastPage, onPageChange }: PaginationPagesProps) { + return ( + + ); +} + +export { PaginationPages }; diff --git a/apps/admin-frontend/src/widgets/pagination/components/pagination-skeleton.tsx b/apps/admin-frontend/src/widgets/pagination/components/pagination-skeleton.tsx new file mode 100644 index 00000000..e09a3d0a --- /dev/null +++ b/apps/admin-frontend/src/widgets/pagination/components/pagination-skeleton.tsx @@ -0,0 +1,22 @@ +import { Skeleton } from '@/shared/ui/skeleton'; +import { PAGES_ROW, PaginationLayout } from './pagination-layout'; + +function PaginationSkeleton() { + return ( +