Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/agenza-form-field/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 4 additions & 6 deletions apps/admin-frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<Page>/route.ts`** e são reexportados pelo barril, para que
`app/routes.tsx` os importe via `@/features/<slice>` e a direção continue `app → features`.
Expand Down
45 changes: 22 additions & 23 deletions apps/admin-frontend/docs/ARCHITECTURE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
34 changes: 5 additions & 29 deletions apps/admin-frontend/specs/003-tags-crud/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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.
101 changes: 59 additions & 42 deletions apps/admin-frontend/src/features/tags/api/tagsRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
38 changes: 24 additions & 14 deletions apps/admin-frontend/src/features/tags/api/tagsRepository.ts
Original file line number Diff line number Diff line change
@@ -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<ApiResult<TagList>> {
return servicesApi.get('/api/v{version}/tags', {
query: { Page: page, PageSize: pageSize, ...(search ? { Search: search } : {}) },
signal,
});

async function list(search?: string, signal?: AbortSignal): Promise<ApiResult<Tag[]>> {
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<ApiResult<Tag>> {
Expand Down
1 change: 0 additions & 1 deletion apps/admin-frontend/src/features/tags/model/tag.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
4 changes: 0 additions & 4 deletions apps/admin-frontend/src/features/tags/model/tagList.ts

This file was deleted.

3 changes: 0 additions & 3 deletions apps/admin-frontend/src/features/tags/model/tagListFilters.ts

This file was deleted.

Loading
Loading