diff --git a/.claude/skills/agenza-form-field/SKILL.md b/.claude/skills/agenza-form-field/SKILL.md index ab1a15d3..ee87f360 100644 --- a/.claude/skills/agenza-form-field/SKILL.md +++ b/.claude/skills/agenza-form-field/SKILL.md @@ -170,7 +170,7 @@ row happened to be holding (router navigation state, a cached list item). The li (paginated, or changed by someone else since); the edit form's job is to show what the backend has *now*. The hook returns a flat result whose `status` is **derived**, not stored: the hook keeps the id of the record currently in the form (`loadedId`) and reports `'loading'` while `isEdit && loadedId !== -id` — the in-flight key compared with the last resolved one. React +id` — the same "compare the in-flight key with the last resolved one" shape as `useTagListPage`. React Router keeps the page mounted when only `:id` changes, so a status set once at mount would keep the old record on screen, Salvar enabled, while the new one loads; the derived one can't. diff --git a/apps/admin-frontend/AGENTS.md b/apps/admin-frontend/AGENTS.md index 07e9b94d..da4caa6a 100644 --- a/apps/admin-frontend/AGENTS.md +++ b/apps/admin-frontend/AGENTS.md @@ -45,12 +45,10 @@ iniciais e o repositório ao `usePaginatedList`, que guarda filtros, página e t ([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", `reload`), 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). Pessoas e Etiquetas usam o mesmo -hook; em Etiquetas os tópicos `tagSaved` e `tagDeleted` chamam o `reload`, que pede de novo a página da tela sem -tirar as linhas dela, conforme a [ADR 0074](../../docs/adr/0074-admin-frontend-tags-list-asks-only-when-told.md). +(`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 eabef6c4..72a80c80 100644 --- a/apps/admin-frontend/docs/ARCHITECTURE.md +++ b/apps/admin-frontend/docs/ARCHITECTURE.md @@ -116,24 +116,24 @@ re-exported from the slice barrel, so `app/routes.tsx` wires them by importing ` — the dependency still runs `app → features`. Server data then reaches the shell through `useLoaderData()` / `useActionData()` / `useFetcher()`, never through page-owned state — this is the shape to reach for when a page has a `loader`, not a requirement every page carries. -`features/tags/ui/pages/TagListPage` doesn't have one today: `useTagListPage.ts` hands -`tagsRepository.list` to `usePaginatedList`, the same page-owned-hook shape every other page in this -app already uses, with a `status: 'loading' | 'ready' | 'error'` derived from the answer the hook -holds (none while a request is out), not stored as its own `setState`. Three different +`features/tags/ui/pages/TagListPage` doesn't have one today: `useTagListPage.ts` fetches +`tagsRepository.list()` directly from a plain `useEffect`, the same page-owned-hook shape every other +page in this app already uses, with a `status: 'loading' | 'ready' | 'error'` derived from comparing +the in-flight query against the last-resolved one, not stored as its own `setState`. Three different `loader`/`action` shapes were built for this exact route and backed out of, each for a different reason — §5 has the full history of all three. **A page's own hook keeps its type declarations in a sibling `use.types.ts`**, the same split `shared/ui/` already uses for `.types.ts` (below) — `useTagListPage.types.ts` is the first one. **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`. -**Lists are the exception**: `usePaginatedList` keeps the filters, the page and the size in its +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)), and Etiquetas, on the -same hook, does too ([ADR 0074](../../../docs/adr/0074-admin-frontend-tags-list-asks-only-when-told.md)). The -hook asks the backend only when told to: from an action (`applyFilters`, a page, a size, the retry, `reload`, -which the tag topics call), 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), ADR 0074). +([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/`). @@ -254,12 +254,12 @@ reference for the API boundary. Its read-by-id and mutation responses are struct to Tag, so those methods forward the facade's result without a mapper. Explicit return annotations remain the compile-time checkpoint against a breaking wire change. -The tags list asks for one page per call, like `clientsRepository.list`: `Page` and `PageSize` -always, `Search` only when it is not blank, and the API's page forwarded as it is. -[ADR 0069](../../../docs/adr/0069-tags-collection-compatibility-adapter.md) records the adapter that -walked every page while the screen still listed the whole catalog, and -[ADR 0074](../../../docs/adr/0074-admin-frontend-tags-list-asks-only-when-told.md) the paged screen -that retired it. +The tags list has an explicit compatibility adapter while the screen still consumes a complete +collection: it reads bounded API pages sequentially, keeps search and cancellation on each request, +and forwards any failure unchanged instead of returning partial results. +[ADR 0069](../../../docs/adr/0069-tags-collection-compatibility-adapter.md) records why the adapter +is part of the backend contract rollout and its retirement trigger: introduce page navigation, +the repository's page contract, the URL and the list hook in the separate frontend feature. **Errors are values, never exceptions.** `Result = { ok: true; data } | { ok: false; error }` with `ok()` / `fail()` in `shared/result.ts` — custom, ~6 lines, no library. No `unwrap()`-that-throws. @@ -305,9 +305,9 @@ _"`Result` é a moeda interna; a fronteira do framework é o caixa."_ first two look identical to the reader if the code doesn't keep them apart. `widgets/list-section` takes an explicit `status: 'loading' | 'error' | 'ready'`, set only inside the `result.ok` branch for `'ready'` and only in the failure branch for `'error'` — `'ready'` is -never set, and `'error'` is never skipped, based on `items.length`. `usePaginatedList` is the reference -implementation, under both lists: it derives `status` from the answer it holds, which a new search, page -or size clears and a request it aborted never sets, rather than storing it (§5). A listing hook's own job +never set, and `'error'` is never skipped, based on `items.length`. `features/tags` is the reference +implementation: `useTagListPage.ts` derives `status` by comparing the query currently in flight +against the last one a response resolved for, rather than storing it (§5). A listing hook's own job is listing — every repository failure maps to `'error'` the same way, and deciding what an expired session means for navigation is deliberately not this hook's problem. That is **not** the same as an expired session being handled somewhere else in the app — right now it isn't; §6 tracks it as an @@ -464,7 +464,6 @@ Chosen, and — just as important — tried and backed out of, so nobody re-liti | `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. | -| Etiquetas pages on `usePaginatedList`, like Pessoas, with its search, page and size out of the URL: `tagsRepository.list({ search, page, pageSize }, signal)` is one request and ADR 0069's adapter is gone; the hook gains `reload()`, which the tag topics call; no link or dialog carries `?q=` any more | Asked for by the owner on 2026-10-20, once ADR 0073 had left Etiquetas as the last list fetching from an effect keyed on state (`?q=`, read with `useSearchParams()`). A first version kept ADR 0069's adapter and a `request`/`send` pair inside `useTagListPage`; reviewing it, the owner asked for the screen to page in the same change, with the pattern Pessoas uses, which is ADR 0069's retirement trigger. The page passes its filters and `tagsRepository.list` to the hook and renders `PaginatedListSection`, so it has Pessoas' footer, skeleton and correction of a page past the end; the search form is `TagListSearch`, capped at the API's 254 characters. `reload()` sends the current query again without clearing the answer, so a save or a delete keeps the rows and the footer on screen until the fresh page arrives (as the topics' reload did since the `reloadToken` row above); the retry would flash the skeleton, and `applyFilters` would go back to page 1. Back and Forward changing `?q=` under the mounted page was itself a reactive trigger, so the owner chose between dropping `?q=` as Pessoas did (ADR 0072), keeping it written with `replace` and read once on mount, and a `popstate` listener; they dropped it. The dialogs are child routes, so the list stays mounted and its state survives opening and closing one. The tests pin one request per action inside `act`, the topics' reload of the page on screen, and the paging. The tag the edit and delete dialogs load by `:id` still comes from an effect keyed on the route parameter. [ADR 0074](../../../docs/adr/0074-admin-frontend-tags-list-asks-only-when-told.md), and spec 003's Clarifications of 2026-10-20 and FR-019. | 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. @@ -522,8 +521,8 @@ because it didn't need to be yet. This is the compiled view across the whole app | `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 | `TagListSearch` and `ClientListSearch` repeat about fifteen lines of markup; the state-and-abort logic of both lists lives in `usePaginatedList` ([ADR 0071](../../../docs/adr/0071-admin-frontend-paged-list-hook.md)) | 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 `usePaginatedList` does | +| `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/specs/003-tags-crud/contracts/routes-contract.md b/apps/admin-frontend/specs/003-tags-crud/contracts/routes-contract.md index 217e6af1..8ba5fd2d 100644 --- a/apps/admin-frontend/specs/003-tags-crud/contracts/routes-contract.md +++ b/apps/admin-frontend/specs/003-tags-crud/contracts/routes-contract.md @@ -27,27 +27,23 @@ own page's loading state (the page doesn't exist yet while the loader runs). `do ## Data contract `/tags` has no `route.ts`, no `loader`, and no `action`. `TagListPage`'s own hook -(`useTagListPage.ts`) hands `tagsRepository.list` to `usePaginatedList`, which calls it with -`{ search, page, pageSize }` and a signal only when told to: once on mount, from the search, -"Limpar busca", a page, a size and "Tentar novamente", from the `tagSaved` and `tagDeleted` topics -(its `reload`), and to correct a page past the end, never from an effect keyed on its state -(ADR 0074). One call is one `GET /tags?Page=&PageSize=[&Search=]`. +(`useTagListPage.ts`) calls `tagsRepository.list(query)` directly from a `useEffect`, keyed on the +submitted search query. -- **Input**: the submitted search, the page and the size, kept in the hook's own state and never in - the URL. `/tags?q=...` (or `?page=`) is ignored: the page opens with an empty field on the first - page of 20, and the search, page and size survive opening and closing a dialog only because the - list stays mounted under it. +- **Input**: the `q` URL search param (`/tags?q=...`), read and written via `useSearchParams()` — not + a `useState`. Submitting the search form sets it (dropping the param entirely when the field is + cleared, rather than leaving `?q=` empty); loading `/tags?q=...` directly pre-fills the field and + seeds the first fetch with it. - **Behavior**: never `unwrapOrThrow`s — a repository failure resolves as `status: 'error'` in the hook's own state, rendered in-page by `shared/ui/list-section` (`docs/ARCHITECTURE.md` §2), **every** failure treated alike. This hook deliberately does not special-case `Session.Missing` / `Authorization.Unauthorized` or redirect anywhere — a listing hook's job is listing, not session lifecycle. That is a known, accepted gap, not a claim that something else catches it: nothing else in the app reacts to those two codes today (`docs/ARCHITECTURE.md` §5/§6 has the audit trail). -- **Loading**: `status` is derived from the answer the hook holds, not stored as its own `setState`: - loading until the first answer, and again on every search, page or size, so the loading skeleton - (one row per item of the page size) shows each time. A topic's reload keeps the rows and the - footer on screen until its answer. Every request aborts the one in flight, and an aborted - request's answer is dropped. +- **Loading**: `status` is derived by comparing the query currently in flight against the last one a + response resolved for, not stored as its own `setState` — true for both the first fetch on mount + and every re-search, so `list-section`'s loading skeleton actually shows both times. An `ignore` + flag in the effect's cleanup discards a stale response if a newer search has since superseded it. ## Contract rules @@ -77,14 +73,13 @@ one was built, found obsolete, and removed — `docs/ARCHITECTURE.md` §5). blocked outcome with the backend's message shown verbatim, not an error boundary. - **On success**: `onConfirm` calls `tagDeleted.publish({ id: tag.id })` — a generic `shared/pubsub` topic owned by `features/tags/model/tagEvents.ts`, not an outlet-context callback. - `useTagListPage` self-subscribes via `useTopic(tagDeleted, reload)`, so the publish - synchronously asks again for the page on screen (ADR 0074), before + `useTagListPage` self-subscribes via `useTopic(tagDeleted, () => fetchTags(query))`, so the publish + synchronously calls the same `fetchTags` function the `query`-change effect calls, before `ConfirmDialog` calls `onOpenChange(false)` — the list refetch is still guaranteed in flight before the navigation back to `/tags` fires, just via a signal `TagDeletePage` doesn't need to know anyone is listening to. `docs/ARCHITECTURE.md` §5 has the full reasoning for the switch. -- **Cancel/close**: goes back in history, or replaces the entry with `/tags` when the dialog was - opened by a deep link; the list stays mounted under the dialog, so its search survives closing or - completing the dialog either way (ADR 0074). +- **Cancel/close**: navigates to `{ pathname: '..', search: location.search }` — the active `?q=` + search survives closing or completing the dialog either way. ### Contract rules diff --git a/apps/admin-frontend/specs/003-tags-crud/spec.md b/apps/admin-frontend/specs/003-tags-crud/spec.md index 471edb99..78577bc7 100644 --- a/apps/admin-frontend/specs/003-tags-crud/spec.md +++ b/apps/admin-frontend/specs/003-tags-crud/spec.md @@ -44,23 +44,6 @@ have, following the backend contract: `string Name, string Color, string? Descri fica disponível — Cancelar desabilitado, ✕ oculto, `Esc` e clique fora ignorados — até a resposta chegar. -### Session 2026-10-20 - -- Q: A busca continua na URL (`/tags?q=...`), sobrevivendo a recarregar a página, a um link - compartilhado e ao Voltar/Avançar? → A: Não. A lista só busca no backend quando uma ação manda (a - busca, "Limpar busca", "Tentar novamente", a troca de página ou de tamanho, uma etiqueta salva ou - excluída) e uma vez ao abrir, e o Voltar trocando `?q=` seria uma busca disparada por reação. A - busca, a página e o tamanho ficam só na tela: continuam ao abrir e fechar os diálogos de nova - etiqueta, edição e exclusão, e recomeçam (busca vazia, página 1, 20 por página) ao recarregar, ao - abrir um link ou ao voltar de outra tela, como em Pessoas (ADR 0074). -- Q: A listagem continua trazendo o catálogo inteiro de uma vez? → A: Não. O backend pagina a - listagem de etiquetas (ADR 0068), e a tela passa a paginar como a de Pessoas, com o mesmo rodapé: - "Exibindo 21–40 de 87 etiquetas", "Itens por página" (10, 20, 50 ou 100; padrão 20) e o - paginador. A paginação deixa de estar fora do escopo e o adaptador que percorria todas as páginas - (ADR 0069) sai. Salvar ou excluir uma etiqueta recarrega a página que está na tela, com a mesma - busca e o mesmo tamanho; se ela deixar de existir (a última etiqueta da última página foi - excluída), a lista vai para a última página que existe (ADR 0074). - ## User Scenarios & Testing *(mandatory)* ### User Story 1 — A pessoa vê e localiza as etiquetas existentes (Priority: P1) @@ -90,9 +73,6 @@ lista passa a mostrar apenas as etiquetas correspondentes, buscadas no backend. 4. **Given** o painel autenticado em qualquer tela, **When** a pessoa abre a paleta de comandos e seleciona "Etiquetas" — ou acessa `/tags` diretamente —, **Then** a tela de gestão de Etiquetas é exibida. -5. **Given** existem mais etiquetas do que cabem numa página, **When** a pessoa abre a tela, **Then** - vê as 20 primeiras, quantas existem no total e o paginador; **When** troca de página ou de - tamanho, **Then** a lista mostra aquela página, mantendo a busca. --- @@ -183,8 +163,8 @@ explicação. ### Functional Requirements -- **FR-001**: O sistema **DEVE** listar as etiquetas do tenant atual, página a página, mostrando - nome, cor e descrição de cada uma. +- **FR-001**: O sistema **DEVE** listar todas as etiquetas do tenant atual, mostrando nome, cor e + descrição de cada uma. - **FR-002**: O sistema **DEVE** permitir localizar etiquetas por nome através de um campo de busca que filtra no backend, disparando a busca apenas quando a pessoa confirma explicitamente (tecla Enter ou botão de busca); **NÃO DEVE** filtrar a listagem já carregada no frontend a cada tecla @@ -224,11 +204,6 @@ explicação. diálogo **NÃO DEVE** poder ser fechado: Cancelar **DEVE** ficar desabilitado, o ✕ **DEVE** ficar oculto e `Esc` e o clique fora **DEVEM** ser ignorados. Quando a resposta chegar, com sucesso o diálogo fecha sozinho; com erro, as saídas voltam a funcionar. -- **FR-019**: A listagem **DEVE** paginar com o mesmo rodapé da lista de Pessoas: o resumo do que - está na tela e do total, o seletor "Itens por página" (10, 20, 50 ou 100; padrão 20) e o - paginador. Uma busca volta à página 1 mantendo o tamanho; trocar o tamanho volta à página 1 - mantendo a busca. Salvar ou excluir uma etiqueta **DEVE** recarregar a página na tela, e uma página - que deixou de existir **DEVE** dar lugar à última que existe, nunca a "Nenhuma etiqueta ainda". ### Key Entities - **Etiqueta (Tag)**: representa um rótulo reutilizável aplicável a Serviços, usado para @@ -254,8 +229,8 @@ explicação. - Sem modelo de papéis ou permissões: qualquer pessoa autenticada do tenant pode gerenciar etiquetas (mesma premissa da Fundação de UI). -- O catálogo de etiquetas é pequeno (dezenas a poucas centenas). Ainda assim o backend pagina a - listagem desde a ADR 0068, e a tela pagina com ele (Clarification de 2026-10-20, FR-019). +- O catálogo de etiquetas é pequeno (dezenas a poucas centenas) — a listagem não precisa de + paginação, alinhado ao endpoint existente do backend. - A tela tem rota própria (`/tags`), fora da área de Serviços, alcançável tanto pela paleta de comandos quanto por um destino próprio na navegação principal fixa — a navegação passa a ter sete destinos. @@ -271,5 +246,6 @@ explicação. - CRUD de Serviços e de Categorias. - Atribuir ou remover etiquetas de um Serviço específico (pertence à tela de Serviço). +- Paginação da listagem de etiquetas. - Qualquer modelo de papéis/permissões para restringir quem gerencia etiquetas. - Cores personalizadas além da paleta fixa. diff --git a/apps/admin-frontend/src/features/tags/api/tagsRepository.test.ts b/apps/admin-frontend/src/features/tags/api/tagsRepository.test.ts index f09029a3..e851d7e6 100644 --- a/apps/admin-frontend/src/features/tags/api/tagsRepository.test.ts +++ b/apps/admin-frontend/src/features/tags/api/tagsRepository.test.ts @@ -13,75 +13,92 @@ vi.mock('@/shared/api/servicesApi', () => ({ })); describe('tagsRepository.list', () => { - const FIRST_PAGE_OF_EVERY_TAG = { search: '', page: 1, pageSize: 20 }; - const EMPTY_PAGE = { ok: true, data: { items: [], totalCount: 0, page: 1, pageSize: 20 } }; + const tag = { id: '1', name: 'VIP', color: '#0d9488', description: null }; + + function page(items: (typeof tag)[], totalCount: number, currentPage = 1) { + return { + ok: true, + data: { items, totalCount, page: currentPage, pageSize: 100 }, + }; + } beforeEach(() => { mockGet.mockReset(); }); - it('always asks for a page and a size, so the screen never depends on the backend defaults', async () => { - mockGet.mockResolvedValue(EMPTY_PAGE); + it('reads the first bounded page and returns its tags to the existing screen', async () => { + const response = page([tag], 1); + mockGet.mockResolvedValueOnce(response); - await tagsRepository.list(FIRST_PAGE_OF_EVERY_TAG); + const result = await tagsRepository.list(); expect(mockGet).toHaveBeenCalledExactlyOnceWith('/api/v{version}/tags', { - query: { Page: 1, PageSize: 20 }, + query: { Page: 1, PageSize: 100 }, signal: undefined, }); + expect(result).toEqual({ ok: true, data: [tag] }); }); - it('sends the page, the size and the search term under the names the API reads, in one request', async () => { - mockGet.mockResolvedValue(EMPTY_PAGE); + it('reads every page in order, preserving search and cancellation on each request', async () => { + const firstTags = Array.from({ length: 100 }, (_, index) => ({ + ...tag, + id: String(index), + })); + const lastTag = { ...tag, id: 'last' }; + const first = page(firstTags, 101); + const second = page([lastTag], 101, 2); + const controller = new AbortController(); + mockGet.mockResolvedValueOnce(first).mockResolvedValueOnce(second); - await tagsRepository.list({ search: 'vip', page: 3, pageSize: 50 }); + const result = await tagsRepository.list('VIP', controller.signal); - expect(mockGet).toHaveBeenCalledExactlyOnceWith('/api/v{version}/tags', { - query: { Page: 3, PageSize: 50, Search: 'vip' }, - signal: undefined, + expect(mockGet).toHaveBeenCalledTimes(2); + expect(mockGet).toHaveBeenNthCalledWith(1, '/api/v{version}/tags', { + query: { Page: 1, PageSize: 100, Search: 'VIP' }, + signal: controller.signal, }); + expect(mockGet).toHaveBeenNthCalledWith(2, '/api/v{version}/tags', { + query: { Page: 2, PageSize: 100, Search: 'VIP' }, + signal: controller.signal, + }); + expect(result).toEqual({ ok: true, data: [...firstTags, lastTag] }); }); - it('leaves Search out, instead of sending it empty, without a search term', async () => { - mockGet.mockResolvedValue(EMPTY_PAGE); - - await tagsRepository.list(FIRST_PAGE_OF_EVERY_TAG); - - const [, options] = mockGet.mock.calls.at(-1) as [string, { query: object }]; - expect(options.query).not.toHaveProperty('Search'); - }); - - it('hands the abort signal over to the request', async () => { - mockGet.mockResolvedValue(EMPTY_PAGE); - const controller = new AbortController(); + it('forwards a failure from the first page unchanged', async () => { + const failure = { + ok: false, + error: { status: 403, code: 'Tenant.ContextMismatch', title: 'Acesso negado.' }, + }; + mockGet.mockResolvedValueOnce(failure); - await tagsRepository.list(FIRST_PAGE_OF_EVERY_TAG, controller.signal); + const result = await tagsRepository.list(); - const [, options] = mockGet.mock.calls.at(-1) as [string, { signal: AbortSignal }]; - expect(options.signal).toBe(controller.signal); + expect(result).toBe(failure); + expect(mockGet).toHaveBeenCalledTimes(1); }); - it('forwards the whole page verbatim, with the items and the paging data', async () => { - const tag = { id: '1', name: 'VIP', color: '#0d9488', description: null }; - const page = { items: [tag], totalCount: 21, page: 2, pageSize: 20 }; - mockGet.mockResolvedValue({ ok: true, data: page }); + it('forwards a later failure unchanged instead of returning a partial catalog', async () => { + const first = page([tag], 101); + const failure = { + ok: false, + error: { status: 0, code: 'Network.Unreachable', title: 'Sem conexão.' }, + }; + mockGet.mockResolvedValueOnce(first).mockResolvedValueOnce(failure); - const result = await tagsRepository.list({ search: '', page: 2, pageSize: 20 }); + const result = await tagsRepository.list(); - expect(result).toEqual({ ok: true, data: page }); + expect(result).toBe(failure); + expect(mockGet).toHaveBeenCalledTimes(2); }); - it('forwards a failed result verbatim, without throwing (e.g. a search past the limit)', async () => { - const error = { - status: 400, - code: 'Validation.Failed', - title: 'A busca deve ter no máximo 254 caracteres.', - }; - mockGet.mockResolvedValue({ ok: false, error }); + it('stops on an empty page even if the reported total is larger', async () => { + const response = page([], 10); + mockGet.mockResolvedValueOnce(response); - const result = await tagsRepository.list({ search: 'x', page: 1, pageSize: 20 }); + const result = await tagsRepository.list(); - expect(result).toEqual({ ok: false, error }); + expect(result).toEqual({ ok: true, data: [] }); + expect(mockGet).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/admin-frontend/src/features/tags/api/tagsRepository.ts b/apps/admin-frontend/src/features/tags/api/tagsRepository.ts index 0195068e..ca445446 100644 --- a/apps/admin-frontend/src/features/tags/api/tagsRepository.ts +++ b/apps/admin-frontend/src/features/tags/api/tagsRepository.ts @@ -1,20 +1,30 @@ import { servicesApi } from '@/shared/api/servicesApi'; +import { ok } from '@/shared/result'; import type { ApiResult } from '@/shared/api/servicesFacade'; -import type { Paging } from '@/shared/pagination/paging'; import type { Tag, TagInput } from '../model/tag'; -import type { TagList } from '../model/tagList'; -import type { TagListFilters } from '../model/tagListFilters'; - -export type TagListQuery = TagListFilters & Paging; - -function list( - { search, page, pageSize }: TagListQuery, - signal?: AbortSignal, -): Promise> { - return servicesApi.get('/api/v{version}/tags', { - query: { Page: page, PageSize: pageSize, ...(search ? { Search: search } : {}) }, - signal, - }); + +async function list(search?: string, signal?: AbortSignal): Promise> { + const tags: Tag[] = []; + let page = 1; + + while (true) { + const result = await servicesApi.get('/api/v{version}/tags', { + query: { Page: page, PageSize: 100, ...(search ? { Search: search } : {}) }, + signal, + }); + + if (!result.ok) return result; + + const tagsPage = result.data; + tags.push(...tagsPage.items); + const lastItem = tagsPage.page * tagsPage.pageSize; + + if (lastItem >= tagsPage.totalCount || tagsPage.items.length === 0) { + return ok(tags); + } + + page += 1; + } } function get(id: string): Promise> { diff --git a/apps/admin-frontend/src/features/tags/model/tag.ts b/apps/admin-frontend/src/features/tags/model/tag.ts index 067452d6..f5958226 100644 --- a/apps/admin-frontend/src/features/tags/model/tag.ts +++ b/apps/admin-frontend/src/features/tags/model/tag.ts @@ -1,6 +1,5 @@ export const TAG_NAME_MAX_LENGTH = 40; export const TAG_DESCRIPTION_MAX_LENGTH = 200; -export const TAG_SEARCH_MAX_LENGTH = 254; export interface Tag { id: string; diff --git a/apps/admin-frontend/src/features/tags/model/tagList.ts b/apps/admin-frontend/src/features/tags/model/tagList.ts deleted file mode 100644 index 66d2fcf0..00000000 --- a/apps/admin-frontend/src/features/tags/model/tagList.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { PagedResult } from '@/shared/pagination/paging'; -import type { Tag } from './tag'; - -export type TagList = PagedResult; diff --git a/apps/admin-frontend/src/features/tags/model/tagListFilters.ts b/apps/admin-frontend/src/features/tags/model/tagListFilters.ts deleted file mode 100644 index e1b459a5..00000000 --- a/apps/admin-frontend/src/features/tags/model/tagListFilters.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface TagListFilters { - search: string; -} diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagDeletePage/TagDeletePage.test.tsx b/apps/admin-frontend/src/features/tags/ui/pages/TagDeletePage/TagDeletePage.test.tsx index a1906fc7..d081fd2c 100644 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagDeletePage/TagDeletePage.test.tsx +++ b/apps/admin-frontend/src/features/tags/ui/pages/TagDeletePage/TagDeletePage.test.tsx @@ -1,6 +1,6 @@ import { StrictMode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, render, screen, waitFor, within } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createMemoryRouter, @@ -12,7 +12,6 @@ import { } from 'react-router'; import { TagListPage } from '../TagListPage/TagListPage'; import { TagDeletePage } from './TagDeletePage'; -import type { TagListQuery } from '../../../api/tagsRepository'; import type { Tag } from '../../../model/tag'; const { mockList, mockGet, mockDelete } = vi.hoisted(() => ({ @@ -32,10 +31,6 @@ const TAGS: Tag[] = [ const PROMOCAO_CONFIRMATION = 'Tem certeza que deseja excluir a etiqueta "Promoção"?'; -function listOf(items: Tag[]) { - return { ok: true, data: { items, totalCount: items.length, page: 1, pageSize: 20 } }; -} - function LocationProbe() { const location = useLocation(); return
{`location: ${location.pathname}${location.search}`}
; @@ -61,7 +56,7 @@ function renderAt(initialEntries: string[]) { describe('TagDeletePage', () => { beforeEach(() => { vi.clearAllMocks(); - mockList.mockResolvedValue(listOf(TAGS)); + mockList.mockResolvedValue({ ok: true, data: TAGS }); mockGet.mockImplementation((id: string) => { const tag = TAGS.find((candidate) => candidate.id === id); return Promise.resolve( @@ -110,14 +105,13 @@ describe('TagDeletePage', () => { mockGet.mockReturnValue(new Promise((resolve) => (resolveGet = resolve))); renderAt(['/tags/1/delete']); - const dialog = await screen.findByRole('dialog'); - expect(within(dialog).getByRole('status')).toHaveTextContent('Carregando…'); + expect(await screen.findByRole('status')).toHaveTextContent('Carregando…'); expect(screen.getByRole('button', { name: 'Excluir' })).toBeDisabled(); resolveGet({ ok: true, data: TAGS[0]! }); expect(await screen.findByText(PROMOCAO_CONFIRMATION)).toBeInTheDocument(); - expect(within(dialog).queryByRole('status')).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Excluir' })).toBeEnabled(); }); @@ -129,7 +123,7 @@ describe('TagDeletePage', () => { await user.click(screen.getByRole('link', { name: 'Excluir Promoção' })); await screen.findByText(PROMOCAO_CONFIRMATION); - mockList.mockResolvedValue(listOf(TAGS.filter((tag) => tag.id !== '1'))); + mockList.mockResolvedValue({ ok: true, data: TAGS.filter((tag) => tag.id !== '1') }); await user.click(screen.getByRole('button', { name: 'Excluir' })); expect(mockDelete).toHaveBeenCalledWith('1'); @@ -228,27 +222,18 @@ describe('TagDeletePage', () => { expect(screen.getByTestId('location').textContent).toBe('location: /tags'); }); - it('closes without deleting when Cancelar is clicked, with the search the list had still on screen (spec US4 scenario 3, ADR 0074)', async () => { + it('closes without deleting when Cancelar is clicked, preserving the active search (spec US4 scenario 3)', async () => { const user = userEvent.setup(); - mockList.mockImplementation(({ search }: TagListQuery) => - Promise.resolve( - listOf(search ? TAGS.filter((tag) => tag.name.toLowerCase().includes(search)) : TAGS), - ), - ); - renderAt(['/tags']); - await screen.findByText('VIP'); - await user.type(screen.getByLabelText('Buscar etiquetas por nome'), 'promo{Enter}'); - await waitFor(() => expect(screen.queryByText('VIP')).not.toBeInTheDocument()); + renderAt(['/tags?q=promo']); + await screen.findByText('Promoção'); await user.click(screen.getByRole('link', { name: 'Excluir Promoção' })); await user.click(screen.getByRole('button', { name: 'Cancelar' })); expect(mockDelete).not.toHaveBeenCalled(); - await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('location: /tags')); - expect(screen.getByLabelText('Buscar etiquetas por nome')).toHaveValue('promo'); - expect(screen.getByText('Promoção')).toBeInTheDocument(); - expect(screen.queryByText('VIP')).not.toBeInTheDocument(); - expect(mockList).toHaveBeenCalledTimes(2); + await waitFor(() => + expect(screen.getByTestId('location').textContent).toBe('location: /tags?q=promo'), + ); }); it('shows the backend message in a toast and returns to the list when the tag does not exist', async () => { diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagDeletePage/useTagDeletePage.ts b/apps/admin-frontend/src/features/tags/ui/pages/TagDeletePage/useTagDeletePage.ts index ffb61275..2a03021f 100644 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagDeletePage/useTagDeletePage.ts +++ b/apps/admin-frontend/src/features/tags/ui/pages/TagDeletePage/useTagDeletePage.ts @@ -19,8 +19,8 @@ export function useTagDeletePage(): UseTagDeletePageResult { void navigate(-1); return; } - void navigate(TAGS_PATH, { replace: true }); - }, [navigate, canGoBack]); + void navigate({ pathname: TAGS_PATH, search: location.search }, { replace: true }); + }, [navigate, canGoBack, location.search]); useEffect(() => { let ignore = false; diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagFormPage/TagFormPage.test.tsx b/apps/admin-frontend/src/features/tags/ui/pages/TagFormPage/TagFormPage.test.tsx index 04361f88..56d1143b 100644 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagFormPage/TagFormPage.test.tsx +++ b/apps/admin-frontend/src/features/tags/ui/pages/TagFormPage/TagFormPage.test.tsx @@ -12,7 +12,6 @@ import { } from 'react-router'; import { TagListPage } from '../TagListPage/TagListPage'; import { TagFormPage } from './TagFormPage'; -import type { TagListQuery } from '../../../api/tagsRepository'; import { TAG_DESCRIPTION_MAX_LENGTH, TAG_NAME_MAX_LENGTH, type Tag } from '../../../model/tag'; const { mockList, mockGet, mockCreate, mockUpdate } = vi.hoisted(() => ({ @@ -31,10 +30,6 @@ const TAGS: Tag[] = [ { id: '2', name: 'VIP', color: '#8b5cf6', description: null }, ]; -function listOf(items: Tag[]) { - return { ok: true, data: { items, totalCount: items.length, page: 1, pageSize: 20 } }; -} - function LocationProbe() { const location = useLocation(); return
{`location: ${location.pathname}${location.search}`}
; @@ -80,7 +75,7 @@ function renderRouterAt(initialEntries: string[]) { describe('TagFormPage', () => { beforeEach(() => { vi.clearAllMocks(); - mockList.mockResolvedValue(listOf(TAGS)); + mockList.mockResolvedValue({ ok: true, data: TAGS }); mockGet.mockImplementation((id: string) => { const tag = TAGS.find((candidate) => candidate.id === id); return Promise.resolve( @@ -130,9 +125,10 @@ describe('TagFormPage', () => { await user.type(screen.getByLabelText('Nome'), 'Sazonal'); await user.click(screen.getByRole('radio', { name: 'Azul' })); - mockList.mockResolvedValue( - listOf([...TAGS, { id: '3', name: 'Sazonal', color: '#0ea5e9', description: null }]), - ); + mockList.mockResolvedValue({ + ok: true, + data: [...TAGS, { id: '3', name: 'Sazonal', color: '#0ea5e9', description: null }], + }); await user.click(screen.getByRole('button', { name: 'Salvar' })); expect(mockCreate).toHaveBeenCalledWith({ @@ -175,7 +171,7 @@ describe('TagFormPage', () => { const user = userEvent.setup(); let resolveCreate!: (result: { ok: true; data: Tag }) => void; mockCreate.mockReturnValue(new Promise((resolve) => (resolveCreate = resolve))); - renderAt(['/tags/new']); + renderAt(['/tags/new?q=promo']); await screen.findByRole('heading', { name: 'Nova etiqueta' }); await user.type(screen.getByLabelText('Nome'), 'Sazonal'); @@ -186,14 +182,14 @@ describe('TagFormPage', () => { expect(screen.getByRole('button', { name: 'Cancelar' })).toBeDisabled(); expect(screen.queryByRole('button', { name: 'Fechar' })).not.toBeInTheDocument(); await user.keyboard('{Escape}'); - expect(screen.getByTestId('location').textContent).toBe('location: /tags/new'); + expect(screen.getByTestId('location')).toHaveTextContent('location: /tags/new?q=promo'); resolveCreate({ ok: true, data: { id: '3', name: 'Sazonal', color: '#0ea5e9', description: null }, }); await waitFor(() => - expect(screen.getByTestId('location').textContent).toBe('location: /tags'), + expect(screen.getByTestId('location')).toHaveTextContent('location: /tags?q=promo'), ); }); @@ -312,30 +308,17 @@ describe('TagFormPage', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); - it('closes without creating when Cancelar is clicked, with the search the list had still on screen (ADR 0074)', async () => { + it('closes without creating when Cancelar is clicked, preserving the active search', async () => { const user = userEvent.setup(); - mockList.mockImplementation(({ search }: TagListQuery) => - Promise.resolve( - listOf(search ? TAGS.filter((tag) => tag.name.toLowerCase().includes(search)) : TAGS), - ), - ); - renderAt(['/tags']); - await screen.findByText('VIP'); - await user.type(screen.getByLabelText('Buscar etiquetas por nome'), 'promo{Enter}'); - await waitFor(() => expect(screen.queryByText('VIP')).not.toBeInTheDocument()); - - await user.click(screen.getByRole('link', { name: 'Nova etiqueta' })); + renderAt(['/tags/new?q=promo']); await screen.findByRole('heading', { name: 'Nova etiqueta' }); + await user.click(screen.getByRole('button', { name: 'Cancelar' })); expect(mockCreate).not.toHaveBeenCalled(); await waitFor(() => - expect(screen.getByTestId('location').textContent).toBe('location: /tags'), + expect(screen.getByTestId('location').textContent).toBe('location: /tags?q=promo'), ); - expect(screen.getByLabelText('Buscar etiquetas por nome')).toHaveValue('promo'); - expect(screen.getByText('Promoção')).toBeInTheDocument(); - expect(screen.queryByText('VIP')).not.toBeInTheDocument(); - expect(mockList).toHaveBeenCalledTimes(2); }); }); @@ -463,17 +446,18 @@ describe('TagFormPage', () => { const nameInput = await screen.findByLabelText('Nome'); await user.clear(nameInput); await user.type(nameInput, 'Promo relâmpago'); - mockList.mockResolvedValue( - listOf([ + mockList.mockResolvedValue({ + ok: true, + data: [ { id: '1', name: 'Promo relâmpago', color: '#f59e0b', description: 'Desconto temporário', }, - TAGS[1]!, - ]), - ); + TAGS[1], + ], + }); await user.click(screen.getByRole('button', { name: 'Salvar' })); expect(mockUpdate).toHaveBeenCalledWith('1', { @@ -580,17 +564,17 @@ describe('TagFormPage', () => { it('replaces a deep-linked dialog with the list on close, so Back does not reopen it either', async () => { const user = userEvent.setup(); - const router = renderRouterAt(['/tags/new']); + const router = renderRouterAt(['/tags/new?q=promo']); await screen.findByRole('heading', { name: 'Nova etiqueta' }); await user.click(screen.getByRole('button', { name: 'Cancelar' })); await waitFor(() => - expect(screen.getByTestId('location').textContent).toBe('location: /tags'), + expect(screen.getByTestId('location').textContent).toBe('location: /tags?q=promo'), ); await act(() => router.navigate(-1)); - expect(screen.getByTestId('location').textContent).toBe('location: /tags'); + expect(screen.getByTestId('location').textContent).toBe('location: /tags?q=promo'); expect(screen.queryByRole('heading', { name: 'Nova etiqueta' })).not.toBeInTheDocument(); }); }); diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagFormPage/useTagFormPage.ts b/apps/admin-frontend/src/features/tags/ui/pages/TagFormPage/useTagFormPage.ts index ece088c3..2425dfe0 100644 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagFormPage/useTagFormPage.ts +++ b/apps/admin-frontend/src/features/tags/ui/pages/TagFormPage/useTagFormPage.ts @@ -37,8 +37,8 @@ export function useTagFormPage(): UseTagFormPageResult { void navigate(-1); return; } - void navigate(TAGS_PATH, { replace: true }); - }, [navigate, canGoBack]); + void navigate({ pathname: TAGS_PATH, search: location.search }, { replace: true }); + }, [navigate, canGoBack, location.search]); useEffect(() => { isMountedRef.current = true; diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/TagListPage.test.tsx b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/TagListPage.test.tsx index 10ddffae..b86218e5 100644 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/TagListPage.test.tsx +++ b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/TagListPage.test.tsx @@ -1,15 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { StrictMode } from 'react'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router'; -import { footerSkeleton } from '@/test/footerSkeleton'; -import { pending } from '@/test/pending'; +import { + createMemoryRouter, + MemoryRouter, + Route, + RouterProvider, + Routes, + useLocation, +} from 'react-router'; import { TagListPage } from './TagListPage'; -import type { TagListQuery } from '../../../api/tagsRepository'; import type { Tag } from '../../../model/tag'; -import { tagDeleted, tagSaved } from '../../../model/tagEvents'; -import type { TagList } from '../../../model/tagList'; const { mockList } = vi.hoisted(() => ({ mockList: vi.fn() })); @@ -17,86 +18,41 @@ vi.mock('../../../api/tagsRepository', () => ({ tagsRepository: { list: mockList }, })); -const SEARCH_LABEL = 'Buscar etiquetas por nome'; -const PAGE_SIZE_LABEL = 'Itens por página'; - -const PROMO: Tag = { - id: '1', - name: 'Promoção', - color: '#f59e0b', - description: 'Desconto temporário', -}; -const VIP: Tag = { id: '2', name: 'VIP', color: '#8b5cf6', description: null }; - -const TAGS = [PROMO, VIP]; - -const FIRST_PAGE_OF_EVERY_TAG = { search: '', page: 1, pageSize: 20 }; - -type ListAnswer = { ok: true; data: TagList }; - -function listOf(items: Tag[], paging: Partial = {}): ListAnswer { - return { - ok: true, - data: { items, totalCount: items.length, page: 1, pageSize: 20, ...paging }, - }; -} - -function manyTags(count: number): Tag[] { - return Array.from({ length: count }, (_, index) => ({ - id: `t${index + 1}`, - name: `Etiqueta ${String(index + 1).padStart(3, '0')}`, - color: '#0d9488', - description: null, - })); -} - -function serve(catalog: Tag[]) { - mockList.mockImplementation(({ search, page, pageSize }: TagListQuery) => { - const needle = search.toLowerCase(); - const matching = needle - ? catalog.filter((tag) => tag.name.toLowerCase().includes(needle)) - : catalog; - const start = (page - 1) * pageSize; - return Promise.resolve( - listOf(matching.slice(start, start + pageSize), { - totalCount: matching.length, - page, - pageSize, - }), - ); - }); -} +const TAGS: Tag[] = [ + { id: '1', name: 'Promoção', color: '#f59e0b', description: 'Desconto temporário' }, + { id: '2', name: 'VIP', color: '#8b5cf6', description: null }, +]; function LocationProbe() { const location = useLocation(); return
{`location: ${location.pathname}${location.search}`}
; } -function tagListPageAt(initialEntries: string[] = ['/tags']) { - return ( +function renderTagListPage(initialEntries: string[] = ['/tags']) { + return render( - + , ); } -function renderTagListPage(initialEntries?: string[]) { - return render(tagListPageAt(initialEntries)); -} - -function renderPage(catalog: Tag[], initialEntries?: string[]) { - serve(catalog); +function renderPage(tags: Tag[], initialEntries?: string[]) { + mockList.mockImplementation((search?: string) => { + const needle = search?.toLowerCase(); + const filtered = needle ? tags.filter((tag) => tag.name.toLowerCase().includes(needle)) : tags; + return Promise.resolve({ ok: true, data: filtered }); + }); return renderTagListPage(initialEntries); } // Only the row-link tests need a real `tags` Route match — relative link resolution needs it to // know `/tags` is the route's own base, which a bare, routeless `` render can't give // it. Every other test above renders the page directly and never inspects an href. -function renderAtTagsRoute(catalog: Tag[]) { - serve(catalog); +function renderAtTagsRoute(tags: Tag[], initialEntries: string[] = ['/tags']) { + mockList.mockResolvedValue({ ok: true, data: tags }); return render( - + } /> @@ -105,178 +61,26 @@ function renderAtTagsRoute(catalog: Tag[]) { ); } -function location() { - return screen.getByTestId('location').textContent; -} - -function signalOf(call: number) { - const [, signal] = (mockList.mock.calls[call] ?? []) as [TagListQuery, AbortSignal?]; - return signal; -} - -function skeletonRows(container: HTMLElement) { - return container.querySelector('[aria-busy="true"]')?.querySelectorAll('tbody tr'); -} - -function submitSearch(term: string) { - fireEvent.change(screen.getByLabelText(SEARCH_LABEL), { target: { value: term } }); - fireEvent.submit(screen.getByRole('search')); -} - describe('TagListPage', () => { beforeEach(() => { vi.clearAllMocks(); }); - describe('when it asks the backend (ADR 0074)', () => { - it('asks once on mount, for the first page of 20 tags with no search', async () => { - renderPage(TAGS); - - expect(await screen.findByText('Promoção')).toBeInTheDocument(); - expect(mockList).toHaveBeenCalledTimes(1); - expect(mockList).toHaveBeenCalledWith(FIRST_PAGE_OF_EVERY_TAG, expect.any(AbortSignal)); - }); - - it('asks twice on mount only under StrictMode, aborting the first of the two development runs', async () => { - serve(TAGS); - - render({tagListPageAt()}); - - expect(await screen.findByText('Promoção')).toBeInTheDocument(); - expect(mockList).toHaveBeenCalledTimes(2); - expect(signalOf(0)?.aborted).toBe(true); - expect(signalOf(1)?.aborted).toBe(false); - }); - - it('does not ask again when the page renders again', async () => { - serve(TAGS); - const { rerender } = renderTagListPage(); - await screen.findByText('Promoção'); - - rerender(tagListPageAt()); - rerender(tagListPageAt()); - - expect(mockList).toHaveBeenCalledTimes(1); - }); - - it('asks exactly once for a search and for "Limpar busca", at the moment of the action', async () => { - renderPage(TAGS); - await screen.findByText('Promoção'); - - act(() => { - submitSearch('zzz'); - expect(mockList).toHaveBeenCalledTimes(2); - expect(mockList).toHaveBeenLastCalledWith( - { search: 'zzz', page: 1, pageSize: 20 }, - expect.any(AbortSignal), - ); - }); - - const clear = await screen.findByRole('button', { name: 'Limpar busca' }); - act(() => { - fireEvent.click(clear); - expect(mockList).toHaveBeenCalledTimes(3); - expect(mockList).toHaveBeenLastCalledWith(FIRST_PAGE_OF_EVERY_TAG, expect.any(AbortSignal)); - }); - - expect(await screen.findByText('Promoção')).toBeInTheDocument(); - expect(mockList).toHaveBeenCalledTimes(3); - }); - - it('asks exactly once for "Tentar novamente", for the same search, at the moment of the click', async () => { - renderPage(TAGS); - await screen.findByText('Promoção'); - mockList.mockResolvedValueOnce({ - ok: false, - error: { title: 'Sem conexão com o servidor. Tente novamente.' }, - }); - act(() => submitSearch('vip')); - - const retry = await screen.findByRole('button', { name: 'Tentar novamente' }); - act(() => { - fireEvent.click(retry); - expect(mockList).toHaveBeenCalledTimes(3); - expect(mockList).toHaveBeenLastCalledWith( - { search: 'vip', page: 1, pageSize: 20 }, - expect.any(AbortSignal), - ); - }); - - expect(await screen.findByText('VIP')).toBeInTheDocument(); - expect(mockList).toHaveBeenCalledTimes(3); - }); - - it.each([ - ['a tag is saved', tagSaved], - ['a tag is deleted', tagDeleted], - ] as const)( - 'asks exactly once for the current search when %s, keeping the rows on screen until the answer', - async (_event, topic) => { - const { container } = renderPage(TAGS); - await screen.findByText('Promoção'); - act(() => submitSearch('vip')); - await waitFor(() => expect(screen.queryByText('Promoção')).not.toBeInTheDocument()); - const reload = pending(); - mockList.mockImplementationOnce(() => reload.promise); - - act(() => { - topic.publish({ id: '2' }); - expect(mockList).toHaveBeenCalledTimes(3); - expect(mockList).toHaveBeenLastCalledWith( - { search: 'vip', page: 1, pageSize: 20 }, - expect.any(AbortSignal), - ); - }); - - expect(screen.getByText('VIP')).toBeInTheDocument(); - expect(container.querySelector('[aria-busy="true"]')).toBeNull(); - expect(screen.getByRole('status')).toHaveTextContent('Exibindo 1 de 1 etiqueta'); - - await act(async () => { - reload.resolve(listOf([{ ...VIP, name: 'VIP Ouro' }])); - await reload.promise; - }); - - expect(screen.getByText('VIP Ouro')).toBeInTheDocument(); - expect(mockList).toHaveBeenCalledTimes(3); - }, - ); - - it('aborts the request in flight when another action starts', () => { - mockList.mockImplementation(() => new Promise(() => {})); - renderTagListPage(); - - act(() => submitSearch('vip')); - - expect(signalOf(0)?.aborted).toBe(true); - expect(signalOf(1)?.aborted).toBe(false); - - act(() => tagSaved.publish({ id: '2' })); - - expect(signalOf(1)?.aborted).toBe(true); - expect(signalOf(2)?.aborted).toBe(false); - }); - - it('aborts the request in flight when the page unmounts', () => { - mockList.mockImplementation(() => new Promise(() => {})); - const { unmount } = renderTagListPage(); - - unmount(); - - expect(signalOf(0)?.aborted).toBe(true); - }); - }); - it('shows the table loading skeleton on first render, before the initial fetch resolves', async () => { - const initial = pending(); - mockList.mockImplementationOnce(() => initial.promise); + let resolveInitial!: (value: { ok: true; data: Tag[] }) => void; + mockList.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveInitial = resolve; + }), + ); const { container } = renderTagListPage(); expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); expect(screen.queryByText('Não foi possível carregar as etiquetas.')).not.toBeInTheDocument(); - initial.resolve(listOf(TAGS)); + resolveInitial({ ok: true, data: TAGS }); await waitFor(() => expect(screen.getByText('Promoção')).toBeInTheDocument()); expect(container.querySelector('[aria-busy="true"]')).toBeNull(); @@ -289,7 +93,7 @@ describe('TagListPage', () => { expect(screen.queryByText('/tags')).not.toBeInTheDocument(); }); - it('lists each tag with its name, color chip and description (spec US1)', async () => { + it('lists every tag with its name, color chip and description (spec US1)', async () => { renderPage(TAGS); expect(await screen.findByText('Promoção')).toBeInTheDocument(); @@ -310,6 +114,16 @@ describe('TagListPage', () => { ); }); + it("carries the active search into a row's delete link", async () => { + renderAtTagsRoute(TAGS, ['/tags?q=promo']); + await screen.findByText('Promoção'); + + expect(screen.getByRole('link', { name: 'Excluir Promoção' })).toHaveAttribute( + 'href', + '/tags/1/delete?q=promo', + ); + }); + it('renders an edit link per row, pointing at that tag under /edit (spec US3)', async () => { renderAtTagsRoute(TAGS); await screen.findByText('Promoção'); @@ -322,12 +136,22 @@ describe('TagListPage', () => { ); }); - it('renders a "Nova etiqueta" link pointing at /tags/new (spec US2)', async () => { - renderAtTagsRoute(TAGS); + it("carries the active search into a row's edit link", async () => { + renderAtTagsRoute(TAGS, ['/tags?q=promo']); + await screen.findByText('Promoção'); + + expect(screen.getByRole('link', { name: 'Editar Promoção' })).toHaveAttribute( + 'href', + '/tags/1/edit?q=promo', + ); + }); + + it('renders a "Nova etiqueta" link pointing at /tags/new, carrying the active search (spec US2)', async () => { + renderAtTagsRoute(TAGS, ['/tags?q=promo']); await screen.findByText('Promoção'); const newTag = screen.getByRole('link', { name: 'Nova etiqueta' }); - expect(newTag).toHaveAttribute('href', '/tags/new'); + expect(newTag).toHaveAttribute('href', '/tags/new?q=promo'); expect(newTag).toHaveClass('bg-primary'); expect(newTag.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'); }); @@ -341,7 +165,9 @@ describe('TagListPage', () => { expect(screen.getByTestId('location')).toHaveTextContent('/tags'); await user.click(screen.getByRole('link', { name: 'Nova etiqueta' })); - await waitFor(() => expect(location()).toBe('location: /tags/new')); + await waitFor(() => + expect(screen.getByTestId('location').textContent).toBe('location: /tags/new'), + ); }); it('shows the failure with the backend message and its code, not the empty-catalog message, when the initial fetch fails (spec FR-012)', async () => { @@ -360,31 +186,30 @@ describe('TagListPage', () => { expect(alert).toHaveTextContent('Não foi possível carregar as etiquetas.'); expect(alert).toHaveTextContent('O servidor está instável. Tente novamente em instantes.'); expect(alert).toHaveTextContent('Código: Server.Unavailable'); - expect(screen.queryByText('Nenhuma etiqueta ainda')).not.toBeInTheDocument(); + expect(screen.queryByText('Nenhum item encontrado.')).not.toBeInTheDocument(); }); it('retries the same search from the failure, showing the skeleton while it reloads', async () => { const user = userEvent.setup(); - mockList.mockResolvedValueOnce(listOf(TAGS)); mockList.mockResolvedValueOnce({ ok: false, error: { title: 'Sem conexão com o servidor. Tente novamente.' }, }); - const retry = pending(); - mockList.mockImplementationOnce(() => retry.promise); - const { container } = renderTagListPage(); - await screen.findByText('Promoção'); - await user.type(screen.getByLabelText(SEARCH_LABEL), 'vip{Enter}'); + let resolveRetry!: (value: { ok: true; data: Tag[] }) => void; + mockList.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRetry = resolve; + }), + ); + const { container } = renderTagListPage(['/tags?q=vip']); await user.click(await screen.findByRole('button', { name: 'Tentar novamente' })); expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); - expect(mockList).toHaveBeenLastCalledWith( - { search: 'vip', page: 1, pageSize: 20 }, - expect.any(AbortSignal), - ); + expect(mockList).toHaveBeenLastCalledWith('vip', expect.any(AbortSignal)); - retry.resolve(listOf([VIP])); + resolveRetry({ ok: true, data: [TAGS[1]!] }); expect(await screen.findByText('VIP')).toBeInTheDocument(); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); @@ -403,127 +228,126 @@ describe('TagListPage', () => { const alert = await screen.findByRole('alert'); expect(alert).toHaveTextContent('Não foi possível carregar as etiquetas.'); expect(alert).toHaveTextContent('Sua sessão expirou. Entre novamente.'); - expect(location()).toBe('location: /tags'); + expect(screen.getByTestId('location').textContent).toBe('location: /tags'); }, ); - it('limits the search field to what the backend accepts', async () => { - renderPage(TAGS); - - expect(screen.getByLabelText(SEARCH_LABEL)).toHaveAttribute('maxlength', '254'); - await screen.findByText('Promoção'); - }); - it('does not filter while typing — search only runs once submitted (backend-driven, not frontend)', async () => { const user = userEvent.setup(); renderPage(TAGS); await screen.findByText('Promoção'); - await user.type(screen.getByLabelText(SEARCH_LABEL), 'vip'); + await user.type(screen.getByLabelText('Buscar etiquetas por nome'), 'vip'); - expect(mockList).toHaveBeenCalledTimes(1); expect(screen.getByText('Promoção')).toBeInTheDocument(); expect(screen.getByText('VIP')).toBeInTheDocument(); }); - it('narrows the list to tags whose name contains the search term after pressing Enter, leaving the address alone (spec US1, ADR 0074)', async () => { + it('narrows the list to tags whose name contains the search term after pressing Enter (spec US1)', async () => { const user = userEvent.setup(); renderPage(TAGS); await screen.findByText('Promoção'); - await user.type(screen.getByLabelText(SEARCH_LABEL), 'vip{Enter}'); + await user.type(screen.getByLabelText('Buscar etiquetas por nome'), 'vip{Enter}'); await waitFor(() => expect(screen.queryByText('Promoção')).not.toBeInTheDocument()); expect(screen.getByText('VIP')).toBeInTheDocument(); - expect(mockList).toHaveBeenLastCalledWith( - { search: 'vip', page: 1, pageSize: 20 }, - expect.any(AbortSignal), - ); - expect(location()).toBe('location: /tags'); }); - it('narrows the list from the Buscar button too (spec US1)', async () => { + it('adds the submitted search term to the URL as ?q=', async () => { const user = userEvent.setup(); renderPage(TAGS); await screen.findByText('Promoção'); - await user.type(screen.getByLabelText(SEARCH_LABEL), 'vip'); - await user.click(screen.getByRole('button', { name: 'Buscar' })); + await user.type(screen.getByLabelText('Buscar etiquetas por nome'), 'vip{Enter}'); - await waitFor(() => expect(screen.queryByText('Promoção')).not.toBeInTheDocument()); - expect(screen.getByText('VIP')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/tags?q=vip')); }); - it('starts on the first page of every tag, with an empty field, whatever the address carries (ADR 0074)', async () => { - renderPage(TAGS, ['/tags?q=vip&page=3&pageSize=10']); + it('reads the initial search term from the URL, pre-filling the field and filtering the fetch', async () => { + renderPage(TAGS, ['/tags?q=vip']); - expect(screen.getByLabelText(SEARCH_LABEL)).toHaveValue(''); - expect(await screen.findByText('Promoção')).toBeInTheDocument(); - expect(mockList).toHaveBeenCalledTimes(1); - expect(mockList).toHaveBeenCalledWith(FIRST_PAGE_OF_EVERY_TAG, expect.any(AbortSignal)); + expect(screen.getByLabelText('Buscar etiquetas por nome')).toHaveValue('vip'); + await waitFor(() => expect(mockList).toHaveBeenCalledWith('vip', expect.any(AbortSignal))); + expect(await screen.findByText('VIP')).toBeInTheDocument(); + expect(screen.queryByText('Promoção')).not.toBeInTheDocument(); }); - it('lists every tag again when the search is submitted blank', async () => { + it('clears the ?q= param instead of leaving it empty, when the search is submitted blank', async () => { const user = userEvent.setup(); - renderPage(TAGS); - await screen.findByText('Promoção'); - await user.type(screen.getByLabelText(SEARCH_LABEL), 'vip{Enter}'); - await waitFor(() => expect(screen.queryByText('Promoção')).not.toBeInTheDocument()); + renderPage(TAGS, ['/tags?q=vip']); + await screen.findByText('VIP'); - await user.clear(screen.getByLabelText(SEARCH_LABEL)); + await user.clear(screen.getByLabelText('Buscar etiquetas por nome')); await user.keyboard('{Enter}'); - expect(await screen.findByText('Promoção')).toBeInTheDocument(); - expect(mockList).toHaveBeenLastCalledWith(FIRST_PAGE_OF_EVERY_TAG, expect.any(AbortSignal)); + await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('location: /tags')); }); - it('shows the table loading skeleton while a search is in flight, replacing the stale rows', async () => { + it('shows the table loading skeleton while a search re-fetch is in flight, replacing the stale rows', async () => { const user = userEvent.setup(); - mockList.mockResolvedValueOnce(listOf(TAGS)); + mockList.mockResolvedValueOnce({ ok: true, data: TAGS }); const { container } = renderTagListPage(); await screen.findByText('Promoção'); - const search = pending(); - mockList.mockImplementationOnce(() => search.promise); + let resolveSearch!: (value: { ok: true; data: Tag[] }) => void; + mockList.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSearch = resolve; + }), + ); - await user.type(screen.getByLabelText(SEARCH_LABEL), 'vip{Enter}'); + await user.type(screen.getByLabelText('Buscar etiquetas por nome'), 'vip{Enter}'); await waitFor(() => expect(container.querySelector('[aria-busy="true"]')).not.toBeNull()); expect(screen.queryByText('Promoção')).not.toBeInTheDocument(); expect(screen.queryByText('VIP')).not.toBeInTheDocument(); - search.resolve(listOf([VIP])); + resolveSearch({ ok: true, data: TAGS.filter((tag) => tag.name === 'VIP') }); await waitFor(() => expect(screen.getByText('VIP')).toBeInTheDocument()); expect(container.querySelector('[aria-busy="true"]')).toBeNull(); }); - it('keeps the newer search result even if the older, superseded request resolves later', async () => { + it('keeps the newer search result even if the older, superseded request resolves later (AbortController guard)', async () => { const user = userEvent.setup(); renderPage(TAGS); await screen.findByText('Promoção'); - const first = pending(); - mockList.mockImplementationOnce(() => first.promise); - const searchInput = screen.getByLabelText(SEARCH_LABEL); + let resolveFirst!: (value: { ok: true; data: Tag[] }) => void; + mockList.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const searchInput = screen.getByLabelText('Buscar etiquetas por nome'); await user.type(searchInput, 'vip{Enter}'); await waitFor(() => expect(mockList).toHaveBeenCalledTimes(2)); - const second = pending(); - mockList.mockImplementationOnce(() => second.promise); + let resolveSecond!: (value: { ok: true; data: Tag[] }) => void; + mockList.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); await user.clear(searchInput); await user.type(searchInput, 'promo{Enter}'); await waitFor(() => expect(mockList).toHaveBeenCalledTimes(3)); + // Resolve the newer (second) search first. await act(async () => { - second.resolve(listOf([PROMO])); + resolveSecond({ ok: true, data: TAGS.filter((tag) => tag.name === 'Promoção') }); await Promise.resolve(); }); expect(screen.getByText('Promoção')).toBeInTheDocument(); expect(screen.queryByText('VIP')).not.toBeInTheDocument(); + // The stale (first) request now resolves late — it must not clobber the newer result. await act(async () => { - first.resolve(listOf([VIP])); + resolveFirst({ ok: true, data: TAGS.filter((tag) => tag.name === 'VIP') }); await Promise.resolve(); }); expect(screen.getByText('Promoção')).toBeInTheDocument(); @@ -535,7 +359,7 @@ describe('TagListPage', () => { renderPage(TAGS); await screen.findByText('Promoção'); - const searchInput = screen.getByLabelText(SEARCH_LABEL); + const searchInput = screen.getByLabelText('Buscar etiquetas por nome'); await user.type(searchInput, 'vip{Enter}'); await waitFor(() => expect(screen.queryByText('Promoção')).not.toBeInTheDocument()); @@ -545,11 +369,53 @@ describe('TagListPage', () => { expect(searchInput).toHaveValue('vip!'); }); + it('shows the search term of the current URL again when Back/Forward change ?q=', async () => { + const user = userEvent.setup(); + mockList.mockImplementation((search?: string) => + Promise.resolve({ + ok: true, + data: search ? TAGS.filter((tag) => tag.name.toLowerCase().includes(search)) : TAGS, + }), + ); + const router = createMemoryRouter([{ path: '/tags', element: }], { + initialEntries: ['/tags'], + }); + render(); + await screen.findByText('Promoção'); + + const searchInput = screen.getByLabelText('Buscar etiquetas por nome'); + await user.type(searchInput, 'vip{Enter}'); + await waitFor(() => expect(screen.queryByText('Promoção')).not.toBeInTheDocument()); + + await act(() => router.navigate(-1)); + + expect(await screen.findByText('Promoção')).toBeInTheDocument(); + expect(searchInput).toHaveValue(''); + + await act(() => router.navigate(1)); + + await waitFor(() => expect(screen.queryByText('Promoção')).not.toBeInTheDocument()); + expect(searchInput).toHaveValue('vip'); + }); + + it('narrows the list to tags whose name contains the search term after clicking the search button, case-insensitively (spec US1)', async () => { + const user = userEvent.setup(); + renderPage(TAGS); + await screen.findByText('Promoção'); + + await user.type(screen.getByLabelText('Buscar etiquetas por nome'), 'vip'); + await user.click(screen.getByRole('button', { name: 'Buscar' })); + + await waitFor(() => expect(screen.queryByText('Promoção')).not.toBeInTheDocument()); + expect(screen.getByText('VIP')).toBeInTheDocument(); + }); + it('says there are no tags yet, without a search action, when the catalog is empty (spec US1)', async () => { renderPage([]); expect(await screen.findByText('Nenhuma etiqueta ainda')).toBeInTheDocument(); expect(screen.getByText('Use o botão Nova etiqueta para cadastrar a primeira.')).toBeVisible(); + expect(screen.queryByText('Nenhum item encontrado.')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Limpar busca' })).not.toBeInTheDocument(); }); @@ -558,180 +424,20 @@ describe('TagListPage', () => { renderPage(TAGS); await screen.findByText('Promoção'); - await user.type(screen.getByLabelText(SEARCH_LABEL), 'zzz{Enter}'); + await user.type(screen.getByLabelText('Buscar etiquetas por nome'), 'zzz{Enter}'); expect(await screen.findByText('Nenhuma etiqueta encontrada para «zzz»')).toBeInTheDocument(); expect(screen.queryByText('Nenhuma etiqueta ainda')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Limpar busca' })).toBeInTheDocument(); }); - it('clears the search and the field, and lists every tag again, from "Limpar busca"', async () => { + it('clears the search, the URL and the field, and lists every tag again, from "Limpar busca"', async () => { const user = userEvent.setup(); - renderPage(TAGS); - await screen.findByText('Promoção'); - await user.type(screen.getByLabelText(SEARCH_LABEL), 'zzz{Enter}'); - + renderPage(TAGS, ['/tags?q=zzz']); await user.click(await screen.findByRole('button', { name: 'Limpar busca' })); expect(await screen.findByText('Promoção')).toBeInTheDocument(); - expect(screen.getByLabelText(SEARCH_LABEL)).toHaveValue(''); - expect(mockList).toHaveBeenLastCalledWith(FIRST_PAGE_OF_EVERY_TAG, expect.any(AbortSignal)); - }); - - describe('paging', () => { - it('says which tags are on screen out of how many, below the table', async () => { - renderPage(manyTags(45)); - - const status = await screen.findByRole('status'); - expect(status).toHaveTextContent('Exibindo 1–20 de 45 etiquetas'); - expect(screen.getByText('Etiqueta 020')).toBeInTheDocument(); - expect(screen.queryByText('Etiqueta 021')).not.toBeInTheDocument(); - const table = screen.getByRole('table', { name: 'Etiquetas' }); - expect(table.compareDocumentPosition(status)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); - }); - - it('draws the footer as a skeleton, with no controls, before the first answer', async () => { - const initial = pending(); - mockList.mockImplementationOnce(() => initial.promise); - const { container } = renderTagListPage(); - - expect(footerSkeleton(container)).not.toBeNull(); - expect(screen.queryByRole('navigation', { name: 'Paginação' })).not.toBeInTheDocument(); - - initial.resolve(listOf(manyTags(45).slice(0, 20), { totalCount: 45 })); - - expect(await screen.findByRole('navigation', { name: 'Paginação' })).toBeInTheDocument(); - expect(footerSkeleton(container)).toBeNull(); - }); - - it('shows 20 skeleton rows, the default page size, on the first load', () => { - mockList.mockImplementationOnce(() => pending().promise); - - const { container } = renderTagListPage(); - - expect(skeletonRows(container)).toHaveLength(20); - }); - - it('goes to the next page, keeping the search and the size, and leaves the address alone', async () => { - const user = userEvent.setup(); - renderPage(manyTags(45)); - await screen.findByText('Etiqueta 001'); - await user.type(screen.getByLabelText(SEARCH_LABEL), 'etiqueta{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('Etiqueta 011')).toBeInTheDocument(); - expect(screen.queryByText('Etiqueta 001')).not.toBeInTheDocument(); - expect(mockList).toHaveBeenLastCalledWith( - { search: 'etiqueta', page: 2, pageSize: 10 }, - expect.any(AbortSignal), - ); - expect(location()).toBe('location: /tags'); - expect(screen.getByRole('status')).toHaveTextContent('Exibindo 11–20 de 45 etiquetas'); - }); - - it('goes back to the first page when the size changes, keeping the search', async () => { - const user = userEvent.setup(); - renderPage(manyTags(45)); - await screen.findByText('Etiqueta 001'); - await user.type(screen.getByLabelText(SEARCH_LABEL), 'etiqueta{Enter}'); - await user.click(await screen.findByRole('button', { name: 'Ir para a página 3' })); - await screen.findByText('Etiqueta 041'); - - await user.click(screen.getByRole('combobox', { name: PAGE_SIZE_LABEL })); - await user.click(await screen.findByRole('option', { name: '50' })); - - expect(await screen.findByText('Etiqueta 045')).toBeInTheDocument(); - expect(mockList).toHaveBeenLastCalledWith( - { search: 'etiqueta', page: 1, pageSize: 50 }, - expect.any(AbortSignal), - ); - expect(screen.getByRole('status')).toHaveTextContent('Exibindo 1–45 de 45 etiquetas'); - }); - - it('starts over on the first page when a search is submitted, keeping the size', async () => { - const user = userEvent.setup(); - renderPage(manyTags(45)); - await screen.findByText('Etiqueta 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('Etiqueta 021'); - - await user.type(screen.getByLabelText(SEARCH_LABEL), 'etiqueta 0{Enter}'); - - expect(await screen.findByText('Etiqueta 001')).toBeInTheDocument(); - expect(mockList).toHaveBeenLastCalledWith( - { search: 'etiqueta 0', page: 1, pageSize: 10 }, - expect.any(AbortSignal), - ); - }); - - it('reloads the page on screen, with its size, when a tag is saved', async () => { - const user = userEvent.setup(); - const catalog = manyTags(45); - renderPage(catalog); - await screen.findByText('Etiqueta 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 2' })); - await screen.findByText('Etiqueta 011'); - - catalog[10] = { ...catalog[10]!, name: 'Etiqueta 011 editada' }; - act(() => tagSaved.publish({ id: 't11' })); - - expect(await screen.findByText('Etiqueta 011 editada')).toBeInTheDocument(); - expect(mockList).toHaveBeenLastCalledWith( - { search: '', page: 2, pageSize: 10 }, - expect.any(AbortSignal), - ); - expect(screen.getByRole('status')).toHaveTextContent('Exibindo 11–20 de 45 etiquetas'); - }); - - it('moves to the page before when the only tag on the last page is deleted', async () => { - const user = userEvent.setup(); - const catalog = manyTags(21); - renderPage(catalog); - await screen.findByText('Etiqueta 001'); - await user.click(await screen.findByRole('button', { name: 'Ir para a página 2' })); - await screen.findByText('Etiqueta 021'); - - catalog.pop(); - act(() => tagDeleted.publish({ id: 't21' })); - - expect(await screen.findByText('Etiqueta 001')).toBeInTheDocument(); - expect(mockList).toHaveBeenNthCalledWith( - 3, - { search: '', page: 2, pageSize: 20 }, - expect.any(AbortSignal), - ); - expect(mockList).toHaveBeenLastCalledWith(FIRST_PAGE_OF_EVERY_TAG, expect.any(AbortSignal)); - expect(screen.queryByText('Nenhuma etiqueta ainda')).not.toBeInTheDocument(); - expect(screen.getByRole('status')).toHaveTextContent('Exibindo 1–20 de 20 etiquetas'); - }); - - it('has no controls when a search finds nothing', async () => { - const user = userEvent.setup(); - renderPage(manyTags(45)); - await screen.findByText('Etiqueta 001'); - - await user.type(screen.getByLabelText(SEARCH_LABEL), 'zzz{Enter}'); - - await screen.findByText('Nenhuma etiqueta 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.' } }); - renderTagListPage(); - - await screen.findByRole('alert'); - expect(screen.queryByRole('navigation', { name: 'Paginação' })).not.toBeInTheDocument(); - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); + expect(screen.getByTestId('location').textContent).toBe('location: /tags'); + expect(screen.getByLabelText('Buscar etiquetas por nome')).toHaveValue(''); }); }); diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/TagListPage.tsx b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/TagListPage.tsx index ffeda7fd..feb19ebd 100644 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/TagListPage.tsx +++ b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/TagListPage.tsx @@ -1,11 +1,14 @@ -import { PlusIcon } from 'lucide-react'; +import { PlusIcon, SearchIcon } from 'lucide-react'; import { Link, Outlet } from 'react-router'; -import type { CountNoun } from '@/shared/format/count'; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from '@/shared/ui/input-group'; import { buttonVariants } from '@/shared/ui/button'; -import type { ListSectionEmptyCopy } from '@/widgets/list-section'; -import { PaginatedListSection } from '@/widgets/paginated-list-section'; +import { ListSection, type ListSectionEmptyCopy } from '@/widgets/list-section'; import { tagColumns } from './components/tagColumns'; -import { TagListSearch } from './components/TagListSearch'; import { useTagListPage } from './useTagListPage'; const EMPTY_COPY: ListSectionEmptyCopy = { @@ -15,34 +18,57 @@ const EMPTY_COPY: ListSectionEmptyCopy = { searchDescription: 'Confira a grafia do nome.', }; -const ITEM_LABEL: CountNoun = { singular: 'etiqueta', plural: 'etiquetas' }; - export function TagListPage() { - const { list, pagination, searchTerm, searchInputRef, onSearchSubmit, onClearSearch } = - useTagListPage(); + const { + tags, + status, + failure, + query, + searchInputRef, + onSearchSubmit, + onClearSearch, + onRetry, + newTagTo, + } = useTagListPage(); return (

Etiquetas

- +
- +
+ + + + + + Buscar + + +
- tag.id} aria-label="Etiquetas" columns={tagColumns()} emptyCopy={EMPTY_COPY} - search={{ term: searchTerm, onClear: onClearSearch }} + search={{ term: query, onClear: onClearSearch }} errorTitle="Não foi possível carregar as etiquetas." - pagination={pagination} - itemLabel={ITEM_LABEL} + failure={failure && { ...failure, onRetry }} /> diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/TagDeleteLink.tsx b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/TagDeleteLink.tsx index 42249f28..04edada8 100644 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/TagDeleteLink.tsx +++ b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/TagDeleteLink.tsx @@ -1,5 +1,5 @@ import { Trash2Icon } from 'lucide-react'; -import { Link } from 'react-router'; +import { Link, useLocation } from 'react-router'; import { buttonVariants } from '@/shared/ui/button'; import type { Tag } from '../../../../model/tag'; @@ -8,9 +8,11 @@ interface TagDeleteLinkProps { } function TagDeleteLink({ tag }: TagDeleteLinkProps) { + const location = useLocation(); + return ( diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/TagEditLink.tsx b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/TagEditLink.tsx index 69ac37fc..facbb518 100644 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/TagEditLink.tsx +++ b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/TagEditLink.tsx @@ -1,5 +1,5 @@ import { PencilIcon } from 'lucide-react'; -import { Link } from 'react-router'; +import { Link, useLocation } from 'react-router'; import { buttonVariants } from '@/shared/ui/button'; import type { Tag } from '../../../../model/tag'; @@ -8,9 +8,11 @@ interface TagEditLinkProps { } function TagEditLink({ tag }: TagEditLinkProps) { + const location = useLocation(); + return ( diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/TagListSearch.tsx b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/TagListSearch.tsx deleted file mode 100644 index ce4c7746..00000000 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/TagListSearch.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { SearchIcon } from 'lucide-react'; -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupInput, -} from '@/shared/ui/input-group'; -import { TAG_SEARCH_MAX_LENGTH } from '../../../../model/tag'; -import type { UseTagListPageResult } from '../useTagListPage.types'; - -type TagListSearchProps = Pick; - -export function TagListSearch({ searchInputRef, onSearchSubmit }: TagListSearchProps) { - return ( -
- - - - - - Buscar - - -
- ); -} diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/useTagListPage.ts b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/useTagListPage.ts index ff684a34..f72318a5 100644 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/useTagListPage.ts +++ b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/useTagListPage.ts @@ -1,39 +1,75 @@ -import { useRef, type SubmitEvent } from 'react'; +import { useCallback, useEffect, useRef, useState, type SubmitEvent } from 'react'; +import { useLocation, useSearchParams } from 'react-router'; +import { extractErrorMessage } from '@/shared/api/servicesFacade'; import { useTopic } from '@/shared/pubsub/useTopic'; -import { usePaginatedList } from '@/widgets/paginated-list-section'; +import { ListSectionStatus } from '@/widgets/list-section'; import { tagsRepository } from '../../../api/tagsRepository'; import { tagDeleted, tagSaved } from '../../../model/tagEvents'; -import type { TagListFilters } from '../../../model/tagListFilters'; -import type { UseTagListPageResult } from './useTagListPage.types'; - -const INITIAL_FILTERS: TagListFilters = { search: '' }; +import type { LoadResult, UseTagListPageResult } from './useTagListPage.types'; export function useTagListPage(): UseTagListPageResult { - const { filters, applyFilters, reload, list, pagination } = usePaginatedList({ - initialFilters: INITIAL_FILTERS, - fetchPage: tagsRepository.list, - }); + const [searchParams, setSearchParams] = useSearchParams(); + const query = searchParams.get('q') ?? ''; + const [result, setResult] = useState(null); const searchInputRef = useRef(null); + const abortControllerRef = useRef(null); + const location = useLocation(); + + const fetchTags = useCallback((forQuery: string) => { + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + void tagsRepository.list(forQuery || undefined, controller.signal).then((apiResult) => { + if (controller.signal.aborted) return; + + setResult({ + query: forQuery, + status: apiResult.ok ? ListSectionStatus.Ready : ListSectionStatus.Error, + tags: apiResult.ok ? apiResult.data : [], + problem: apiResult.ok ? null : apiResult.error, + }); + }); + }, []); - useTopic(tagDeleted, reload); - useTopic(tagSaved, reload); + useEffect(() => { + fetchTags(query); + return () => { + abortControllerRef.current?.abort(); + }; + }, [query, fetchTags]); + + useEffect(() => { + const input = searchInputRef.current; + if (input && input.value !== query) input.value = query; + }, [query]); + + useTopic(tagDeleted, () => fetchTags(query)); + useTopic(tagSaved, () => fetchTags(query)); + + const newTagTo = { pathname: 'new', search: location.search }; function onSearchSubmit(event: SubmitEvent) { event.preventDefault(); - applyFilters({ search: searchInputRef.current?.value ?? '' }); + const nextQuery = searchInputRef.current?.value ?? ''; + setSearchParams(nextQuery ? { q: nextQuery } : {}); } - function onClearSearch() { - if (searchInputRef.current) searchInputRef.current.value = ''; - applyFilters({ search: '' }); - } + const isCurrent = result !== null && result.query === query; + const problem = isCurrent ? result.problem : null; return { - list, - pagination, - searchTerm: filters.search, + status: isCurrent ? result.status : ListSectionStatus.Loading, + tags: isCurrent ? result.tags : [], + failure: problem && { message: extractErrorMessage(problem), code: problem.code ?? undefined }, + query, searchInputRef, onSearchSubmit, - onClearSearch, + onClearSearch: () => setSearchParams({}), + onRetry: () => { + setResult(null); + fetchTags(query); + }, + newTagTo, }; } diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/useTagListPage.types.ts b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/useTagListPage.types.ts index 606b2ec6..1491f2cf 100644 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/useTagListPage.types.ts +++ b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/useTagListPage.types.ts @@ -1,15 +1,28 @@ import type { RefObject, SubmitEvent } from 'react'; -import type { - PaginatedListSectionPaging, - PaginatedListState, -} from '@/widgets/paginated-list-section'; +import type { ApiProblem } from '@/shared/api/servicesFacade'; +import type { ListSectionStatus } from '@/widgets/list-section'; import type { Tag } from '../../../model/tag'; +export interface TagListFailure { + message: string; + code: string | undefined; +} + export interface UseTagListPageResult { - list: PaginatedListState; - pagination: PaginatedListSectionPaging; - searchTerm: string; + status: ListSectionStatus; + tags: Tag[]; + failure: TagListFailure | null; + query: string; searchInputRef: RefObject; onSearchSubmit: (event: SubmitEvent) => void; onClearSearch: () => void; + onRetry: () => void; + newTagTo: { pathname: string; search: string }; +} + +export interface LoadResult { + query: string; + status: Exclude; + tags: Tag[]; + problem: ApiProblem | null; } 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 index b912a415..05374f38 100644 --- 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 @@ -46,7 +46,6 @@ interface PaginatedListState { interface UsePaginatedListResult { filters: F; applyFilters: (patch: Partial) => void; - reload: () => void; list: PaginatedListState; pagination: PaginatedListSectionPaging; } 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 index ca752060..97dcc414 100644 --- 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 @@ -222,64 +222,6 @@ describe('usePaginatedList', () => { expect(result.current.pagination.totalCount).toBe(25); }); - it('asks again for the same filters, page and size on a reload, keeping the rows and the total until the answer', async () => { - const { result, fetchPage } = renderList({ ...NO_FILTERS, search: 'ana' }); - await waitFor(() => expect(result.current.list.status).toBe('ready')); - act(() => result.current.pagination.onPageSizeChange(10)); - act(() => result.current.pagination.onPageChange(3)); - await waitFor(() => expect(result.current.list.status).toBe('ready')); - const reloaded = pending(); - fetchPage.mockImplementationOnce(() => reloaded.promise); - - act(() => { - result.current.reload(); - expect(fetchPage).toHaveBeenCalledTimes(4); - }); - - expect(fetchPage).toHaveBeenLastCalledWith( - { ...NO_FILTERS, search: 'ana', page: 3, pageSize: 10 }, - expect.any(AbortSignal), - ); - expect(result.current.list).toMatchObject({ status: 'ready', items: [{ id: '1' }] }); - expect(result.current.pagination.totalCount).toBe(87); - - await act(async () => { - reloaded.resolve(pageOf(88, [{ id: 'reloaded' }])); - await reloaded.promise; - }); - - expect(result.current.list.items).toEqual([{ id: 'reloaded' }]); - expect(result.current.pagination.totalCount).toBe(88); - }); - - it('aborts the request in flight when it reloads', async () => { - const { result, fetchPage } = renderList(); - await waitFor(() => expect(result.current.list.status).toBe('ready')); - fetchPage.mockImplementation(() => new Promise(() => {})); - act(() => result.current.pagination.onPageChange(2)); - - act(() => result.current.reload()); - - expect(signalOf(fetchPage, 1)?.aborted).toBe(true); - expect(signalOf(fetchPage, 2)?.aborted).toBe(false); - expect(fetchPage.mock.calls[2]?.[0]).toEqual(fetchPage.mock.calls[1]?.[0]); - }); - - it('moves to the last page that exists when a reload finds fewer items than the page on screen', async () => { - const { result, fetchPage } = renderList(); - await waitFor(() => expect(result.current.list.status).toBe('ready')); - act(() => result.current.pagination.onPageChange(5)); - await waitFor(() => expect(result.current.list.status).toBe('ready')); - fetchPage.mockResolvedValueOnce(pageOf(80, [])).mockResolvedValueOnce(pageOf(80)); - - act(() => result.current.reload()); - - await waitFor(() => expect(fetchPage).toHaveBeenCalledTimes(4)); - expect(fetchPage.mock.calls.map(([query]) => query.page)).toEqual([1, 5, 5, 4]); - await waitFor(() => expect(result.current.list.status).toBe('ready')); - expect(result.current.pagination).toMatchObject({ page: 4, totalCount: 80 }); - }); - it('asks again for the same page from a failure', async () => { const fetchPage = vi.fn(() => Promise.resolve({ ok: false, error: NETWORK_PROBLEM }), 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 index 2e31072a..02eec2ab 100644 --- 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 @@ -108,16 +108,11 @@ export function usePaginatedList({ request(query); } - function reload() { - send(query); - } - const totalCount = knownTotal?.filters === filters ? knownTotal.totalCount : null; return { filters, applyFilters, - reload, list: toListState(result, onRetry), pagination: { page, pageSize, totalCount, onPageChange, onPageSizeChange }, }; diff --git a/docs/adr/0069-tags-collection-compatibility-adapter.md b/docs/adr/0069-tags-collection-compatibility-adapter.md index 6df57994..4fa82d5b 100644 --- a/docs/adr/0069-tags-collection-compatibility-adapter.md +++ b/docs/adr/0069-tags-collection-compatibility-adapter.md @@ -2,9 +2,7 @@ Status: accepted (2026-10-20); supersedes the frontend-compatibility consequence and the collection-adapter rejection in [ADR 0068](0068-tags-paginated-list.md). The backend paging -contract in that ADR is unchanged. The adapter was retired by -[ADR 0074](0074-admin-frontend-tags-list-asks-only-when-told.md), which pages the screen; this -record stays as the decision that held until then. +contract in that ADR is unchanged. ## Context diff --git a/docs/adr/0073-admin-frontend-paged-list-asks-only-when-told.md b/docs/adr/0073-admin-frontend-paged-list-asks-only-when-told.md index dfa37615..1eec0158 100644 --- a/docs/adr/0073-admin-frontend-paged-list-asks-only-when-told.md +++ b/docs/adr/0073-admin-frontend-paged-list-asks-only-when-told.md @@ -4,9 +4,7 @@ Status: accepted (2026-10); amends [ADR 0072](0072-admin-frontend-paged-list-sta no effect keyed on the state, and the correction of a page past the end leaves the render) and [ADR 0071](0071-admin-frontend-paged-list-hook.md) (point 1: `setFilters` becomes `applyFilters`); follows [ADR 0033](0033-admin-frontend-no-request-cancellation-layer.md) (cancellation stays with the consumer) and -[ADR 0035](0035-admin-frontend-no-server-state-library.md); extended by -[ADR 0074](0074-admin-frontend-tags-list-asks-only-when-told.md) (Etiquetas pages on the same hook, which gains `reload` -for the tag topics, with its state out of the URL) +[ADR 0035](0035-admin-frontend-no-server-state-library.md) ## Context diff --git a/docs/adr/0074-admin-frontend-tags-list-asks-only-when-told.md b/docs/adr/0074-admin-frontend-tags-list-asks-only-when-told.md deleted file mode 100644 index 4ce49357..00000000 --- a/docs/adr/0074-admin-frontend-tags-list-asks-only-when-told.md +++ /dev/null @@ -1,104 +0,0 @@ -# ADR 0074 — admin-frontend: the Etiquetas list pages on `usePaginatedList`, asks the backend only when told to, and keeps its state out of the URL - -Status: accepted (2026-10); extends [ADR 0073](0073-admin-frontend-paged-list-asks-only-when-told.md) (Etiquetas now -pages on the same hook, which gains `reload`) and follows [ADR 0071](0071-admin-frontend-paged-list-hook.md) (one hook -for the paged lists), [ADR 0072](0072-admin-frontend-paged-list-state-in-the-hook.md) (the state leaves the URL, as -Pessoas' did) and [ADR 0033](0033-admin-frontend-no-request-cancellation-layer.md) (cancellation stays with the -consumer); retires the collection adapter of [ADR 0069](0069-tags-collection-compatibility-adapter.md), as that ADR's -retirement trigger said - -## Context - -ADR 0073 left Etiquetas as the last list fetching from an effect keyed on state: `useTagListPage` read `?q=` with -`useSearchParams()` and called `fetchTags(query)` from a `useEffect` keyed on it, and a second effect copied `?q=` -into the uncontrolled search field when Back or Forward changed it. The retry and the `tagDeleted` and `tagSaved` -topics already called `fetchTags` directly. On 2026-10-20 the owner asked that no data fetching in the application -be driven by an effect keyed on state, for the reasons ADR 0073 records. - -The tags API pages on every request since ADR 0068, but the screen still listed the whole catalog: -ADR 0069 kept `tagsRepository.list(search, signal)` as an adapter that walked every page of 100 and returned one -array, until a frontend change paged the screen. The first version of this decision kept that adapter and gave -`useTagListPage` a `request`/`send` pair of its own, and set paging aside as separate work. Reviewing it on -2026-10-20, the owner asked for Etiquetas to page in the same change, with the pattern Pessoas already uses. - -Moving the requests into handlers is not enough while the state lives in the URL: Back and Forward change `?q=` -under a mounted page, and something has to notice, which is the reactive trigger being removed. The owner was asked -to choose between dropping `?q=`, as Pessoas did (ADR 0072), and keeping it with one of the two ways listed under -Considered and rejected, and chose on 2026-10-20 to drop it. - -## Decision - -1. **Etiquetas runs on `usePaginatedList`, like Pessoas.** `model/tagListFilters.ts` declares `TagListFilters` - (`search`) and `model/tagList.ts` declares `TagList` (`PagedResult`). `useTagListPage` passes the initial - filters and `tagsRepository.list` to the hook and keeps only the search field's ref and its two handlers. The - page renders `PaginatedListSection` with the noun "etiqueta"/"etiquetas", so it gets the footer Pessoas has (the - summary, "Itens por página" with 10, 20, 50 or 100 and 20 by default, the pager), the skeleton with one row per - item of the page size, and the correction of a page past the end. The search form is `TagListSearch`, beside the - page, as `ClientListSearch` is, and it caps the field at 254 characters, the `SearchText` limit the API applies. -2. **One request of the hook is one HTTP call.** `tagsRepository.list({ search, page, pageSize }, signal)` sends - `Page` and `PageSize` always and `Search` only when it is not blank, and returns the API's page as it is, like - `clientsRepository.list`. ADR 0069's adapter, which walked every page, is gone. -3. **`usePaginatedList` gains `reload()`**, the action the tag topics call: it sends the current filters, page and - size again through the same `send` (so it aborts what is in flight), without the `request` step that clears the - answer. The rows and the footer stay on screen until the fresh answer replaces them, as the topics' reload did - before, and an answer that finds the page past the end (the last tag of the last page was deleted) is corrected - to the last page like any other. `useTagListPage` subscribes it with `useTopic(tagSaved, reload)` and - `useTopic(tagDeleted, reload)`. It is still an action, called from a topic's handler at the moment of the - publish, so the rule of ADR 0073 holds: a request leaves from the mount, an action or the correction of a page - past the end, and never from an effect keyed on the state. -4. **The search, the page and the size stay out of the URL.** They live in the hook, `?q=` is neither read nor - written, and the field stays uncontrolled, named `search`, without a `defaultValue`; "Limpar busca" empties it in - its own handler. "Nova etiqueta", each row's edit and delete links, and the dialogs' close when there is no history - to go back to stop carrying `location.search`. The dialogs are child routes rendered in the list's ``, so - the list stays mounted while one is open, and its search, page and size survive opening and closing it. -5. **The tests pin it.** `TagListPage.test.tsx`: one request on mount for the first page of 20 (two under StrictMode, - the first aborted), none when the page renders again or while typing, exactly one for each action at the moment - of the action (a search, "Limpar busca", "Tentar novamente", each topic; asserted inside `act`), the abort on the - next action and on unmount, the rows and the footer kept while a topic reloads, an address carrying `?q=` or - `?page=` ignored, and the paging (the summary, the footer skeleton, 20 skeleton rows, Próxima keeping the search - and the size, a size or a search going back to page 1, a saved tag reloading the page on screen with its size, and - the deletion of the only tag of the last page moving to the page before). `use-paginated-list.test.tsx` pins - `reload` on its own. `TagFormPage` and `TagDeletePage` pin that cancelling a dialog leaves the search, the field - and the rows as they were, with no new request. - -## Consequences - -- **Etiquetas keeps its search, page and size only on screen.** A reload, a shared link, Back and Forward, and a - return from another screen start over on the first page of 20 with no search. Accepted by the owner on - 2026-10-20. ARCHITECTURE §1's rule that state a person expects to survive a reload belongs in the URL names both - lists as the exception. -- Confirming the same search again is a new request, with the skeleton. Clicking Etiquetas in the menu while a - search is on screen keeps it: the address is already `/tags`. Pessoas behaves the same since ADR 0072. -- **A tag created or renamed appears where the order puts it.** The reload asks for the page on screen; the API - orders by name, so a new tag that falls on another page does not show on this one, and the total in the summary - grows by one. With the whole catalog on one screen it always showed. -- The screen no longer loads the whole filtered catalog: one request per page, bounded by the page size, instead of - one per 100 tags. -- `reload` is part of the generic hook. Pessoas does not call it yet; its edit screen (#156) can, when it lands. -- The confirm dialog's "Carregando…" and the footer's summary are both `role="status"` on the same screen now; - the delete dialog's test looks for its own inside the dialog. -- `TagListSearch` and `ClientListSearch` are the same fifteen lines with a different label; ARCHITECTURE §6 keeps the - trigger for a shared search form at the third list. -- Spec 003 records the change as two Clarifications of 2026-10-20 and FR-019, moves paging out of Out of Scope, and - its routes contract describes the paged call. -- Still fetched from an effect keyed on state, and outside this decision because it is not a list: the tag the - edit and delete dialogs load by `:id` (`useTagFormPage`, `useTagDeletePage`), keyed on the route parameter. - -## Considered and rejected - -- **Keeping ADR 0069's adapter and a `request`/`send` pair inside `useTagListPage`** (the first version of this - decision). It removed the effect, but left a second copy of the hook's abort-and-answer logic beside - `usePaginatedList` and the screen loading every tag. Superseded by the owner's request to page now. -- **The topics calling the retry, or `applyFilters({})`.** The retry clears the answer, so every save or delete would - flash the skeleton under the closing dialog; `applyFilters` also starts over on page 1, so editing a tag on page 3 - would throw the person back to the first page. -- **Keeping `?q=`, written with `replace` and read once on mount.** A reload and a shared link would keep the - search, and with no history entry per search Back leaves Etiquetas instead of changing `?q=` under the page. But - the URL becomes a mirror the list does not follow: the menu's link to `/tags`, clicked during a search, would - change the address and leave the rows of the search on screen. Turned down by the owner. -- **Keeping `?q=` and Back walking through searches, through a `popstate` listener** subscribed by the mount - effect, like `useTopic`. It keeps everything the URL gave, but reads `window.location` beside the router, and a - navigation to `/tags` that is not a Back or Forward still goes unnoticed. Turned down by the owner. -- **A `loader` on the route.** The router asks on every navigation, Back included, and aborts the one it replaces, - but a blocking loader hides the list's skeleton (the reason it was backed out twice, ARCHITECTURE §5), and a - deferred one needs the Suspense restructuring ADR 0073 set aside. diff --git a/docs/adr/README.md b/docs/adr/README.md index 4c301c76..8866f457 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -93,9 +93,8 @@ instruction files reinstated · 0042 widgets layer and layer-boundaries lint rul 0066 enums read from a query or a route by one name · 0067 admin-frontend Pessoas list: whole CPF, filter and paging deferred, provisional detail route · 0068 tags list: always paged, shared paging contract and literal name search · -0069 tags frontend: collection compatibility over bounded API pages during the paging rollout (retired by 0074) · +0069 tags frontend: collection compatibility over bounded API pages during the paging rollout · 0070 admin-frontend paged lists: page and size in the URL, one footer shared through PaginatedListSection · 0071 admin-frontend paged lists share one hook: filters read by each list, the total kept per filters · 0072 admin-frontend paged lists keep their state in the hook, not in the URL · -0073 admin-frontend paged lists ask the backend only when an action tells them to · -0074 admin-frontend Etiquetas pages on usePaginatedList, asks only when told to, with its state out of the URL. +0073 admin-frontend paged lists ask the backend only when an action tells them to.