From 0726ad27c57b9039bc743b98cf645b8657ba19ad Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Sun, 2 Aug 2026 12:20:40 -0300 Subject: [PATCH] Remove Tags feature from frontend, keep backend Tag API intact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split out of #69 (final part of this series — see that PR for the full picture). Recreated from origin/main after #70/#71/#75 merged, since this repo's convention (and the split-large-coderabbit-pr skill) is a sequential series, not stacking on an unmerged branch. Same content as the original #73, just re-based; no functional change. Removes the entire Tags vertical from apps/admin-frontend (domain, application, infrastructure, presentation, MSW handlers, E2E specs, nav entry, route, and catalog facade wiring) while intentionally retaining the backend Tag domain entity and /api/v1/tags endpoints, including Service's many-to-many relationship to Tag - a project-owner decision, see docs/adr/016-remove-tags-frontend.md. Categories replaces Tags as the reference CRUD implementation throughout the docs and the agenza-frontend-feature skill. ## Test plan - [x] `npm install` + `npm run build --workspace=apps/admin-frontend` — green - [x] `npm run lint --workspace=apps/admin-frontend` — clean, 0 warnings - [x] `npm run format:check --workspace=apps/admin-frontend` — clean - [x] `npm run test --workspace=apps/admin-frontend` — 305/305 passing - [x] `npx playwright test` (full e2e suite, production build + preview) — 8/8 passing - [x] `scripts/sync_agent_skills.py --check`, `scripts/check_agent_governance.py`, `scripts/architecture_guard.py` — all pass Co-Authored-By: Claude Sonnet 5 --- apps/admin-frontend/.agent.md | 4 +- apps/admin-frontend/.env.example | 2 +- apps/admin-frontend/AGENTS.md | 34 +- apps/admin-frontend/docs/API.md | 63 +-- apps/admin-frontend/docs/DOMAIN.md | 28 +- apps/admin-frontend/docs/STATUS.md | 72 ++-- .../docs/adr/016-remove-tags-frontend.md | 64 +++ .../e2e/authenticated-shell.spec.ts | 10 +- apps/admin-frontend/e2e/tags-crud.spec.ts | 105 ----- .../e2e/tags-list-retry.spec.ts | 46 --- apps/admin-frontend/eslint.config.js | 4 +- .../src/app/composition/container.test.ts | 6 +- .../src/app/composition/container.ts | 23 +- .../src/app/layouts/AdminLayout.test.tsx | 1 - .../src/app/layouts/AdminLayout.tsx | 2 - apps/admin-frontend/src/app/routes/router.tsx | 17 - .../application/repositories/TagRepository.ts | 26 -- .../test-helpers/createFakeTagRepository.ts | 19 - .../catalog/domain/entities/Tag.test.ts | 78 ---- .../features/catalog/domain/entities/Tag.ts | 74 ---- .../catalog/domain/errors/InvalidTagError.ts | 3 - .../src/features/catalog/index.ts | 10 +- .../infrastructure/mappers/tagMapper.test.ts | 92 ----- .../infrastructure/mappers/tagMapper.ts | 48 --- .../repositories/ApiTagRepository.test.ts | 141 ------- .../repositories/ApiTagRepository.ts | 68 ---- .../presentation/tags/TagsPage.test.tsx | 378 ------------------ .../catalog/presentation/tags/TagsPage.tsx | 79 ---- .../tags/components/TagsTable.tsx | 104 ----- .../presentation/tags/forms/TagForm.test.tsx | 168 -------- .../presentation/tags/forms/TagForm.tsx | 148 ------- .../presentation/tags/forms/tagFieldMaps.ts | 17 - .../presentation/tags/hooks/useTagEditor.ts | 124 ------ .../presentation/tags/hooks/useTags.test.tsx | 244 ----------- .../presentation/tags/hooks/useTags.ts | 110 ----- .../presentation/tags/hooks/useTagsPage.ts | 56 --- .../tags/pages/TagEditorDialog.tsx | 63 --- .../components/DeleteConfirmationDialog.tsx | 2 +- .../hooks/useDeleteConfirmation.ts | 2 +- .../test/fixtures/createFakeAppContainer.ts | 4 - .../src/test/mocks/handlers/index.ts | 3 +- .../src/test/mocks/handlers/tagHandlers.ts | 27 -- 42 files changed, 165 insertions(+), 2404 deletions(-) create mode 100644 apps/admin-frontend/docs/adr/016-remove-tags-frontend.md delete mode 100644 apps/admin-frontend/e2e/tags-crud.spec.ts delete mode 100644 apps/admin-frontend/e2e/tags-list-retry.spec.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/repositories/TagRepository.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/test-helpers/createFakeTagRepository.ts delete mode 100644 apps/admin-frontend/src/features/catalog/domain/entities/Tag.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/domain/entities/Tag.ts delete mode 100644 apps/admin-frontend/src/features/catalog/domain/errors/InvalidTagError.ts delete mode 100644 apps/admin-frontend/src/features/catalog/infrastructure/mappers/tagMapper.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/infrastructure/mappers/tagMapper.ts delete mode 100644 apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiTagRepository.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiTagRepository.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/tags/TagsPage.test.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/tags/TagsPage.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/tags/components/TagsTable.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/tags/forms/TagForm.test.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/tags/forms/TagForm.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/tags/forms/tagFieldMaps.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTagEditor.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTags.test.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTags.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTagsPage.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/tags/pages/TagEditorDialog.tsx delete mode 100644 apps/admin-frontend/src/test/mocks/handlers/tagHandlers.ts diff --git a/apps/admin-frontend/.agent.md b/apps/admin-frontend/.agent.md index 252041e..826cbab 100644 --- a/apps/admin-frontend/.agent.md +++ b/apps/admin-frontend/.agent.md @@ -46,7 +46,7 @@ When the user asks about features, infrastructure, or cross-cutting concerns, pr - A page component is a shell that wires a controller hook's view models into UI components — nothing else. - Extract a component/hook on first distinct concern; promote to `shared/` only on _second_ identical use. - Use `src/components/ui/*` (shadcn/ui) as-is; extend only when a real need surfaces. -- Reference implementations: `TagsPage` (behavior & design), `TagForm` (form structure), `AdminLayout` (page shell). +- Reference implementations: `CategoriesListPage`/`CategoryEditorDialog` (behavior, design & form structure), `AdminLayout` (page shell). ### Testing @@ -107,7 +107,7 @@ Use this agent when: **Example prompts:** -- "Add a new `ClientsPage` following the Tags reference pattern" +- "Add a new `ClientsPage` following the Categories reference pattern" - "Review this controller hook for over-complexity; should I split it?" - "Why does the component forward a `ref` to the DOM? When is that required?" - "Add MSW handlers for these endpoints and wire them into the test" diff --git a/apps/admin-frontend/.env.example b/apps/admin-frontend/.env.example index 1079ab7..0cfd617 100644 --- a/apps/admin-frontend/.env.example +++ b/apps/admin-frontend/.env.example @@ -4,7 +4,7 @@ # `dotnet run --project backend/AppHost`. # # services-api must stay in scope: it's the audience services-service's -# AddIdentityServiceAuthentication checks, so REST calls (e.g. /api/tags) +# AddIdentityServiceAuthentication checks, so REST calls (e.g. /api/categories) # 401 without it. VITE_OIDC_AUTHORITY=http://localhost:5081 diff --git a/apps/admin-frontend/AGENTS.md b/apps/admin-frontend/AGENTS.md index 83668f7..32a99ff 100644 --- a/apps/admin-frontend/AGENTS.md +++ b/apps/admin-frontend/AGENTS.md @@ -9,8 +9,10 @@ covers what's specific to `apps/admin-frontend/`. A multi-tenant SaaS admin panel for small healthcare/wellness businesses. Built with Clean Architecture, TDD, and strict TypeScript, organized by feature (ADR 009: `app/`, `features/{auth,catalog}/`, `shared/`). The Auth, -Tags, Categories, and Services verticals are complete end-to-end (frontend + -backend). The remaining feature verticals (Appointments, Clients, Inbox, +Categories, and Services verticals are complete end-to-end (frontend + +backend). Tags was removed from the frontend (docs/adr/016) — the backend +`Tag` domain/`/api/v1/tags` endpoints are intentionally retained. The +remaining feature verticals (Appointments, Clients, Inbox, Dashboard, Settings) are stubs awaiting implementation, under `app/pages/` until each graduates into its own feature. @@ -67,7 +69,7 @@ infrastructure/presentation`. ESLint (`no-restricted-imports`) and (MSW fixtures need a feature's internal DTOs) and `app/routes/router.tsx` lazy-loading catalog's pages by their own path (code-splitting — see docs/adr/009's "Execution" section for why). -- Tags, Categories, and Services share one `features/catalog/` feature +- Categories and Services share one `features/catalog/` feature (not one each) — they collaborate in the same business context and cross-reference each other (a Service has a `categoryId` and `tags`). - `app/composition/container.ts` is the ONLY place allowed to construct @@ -147,11 +149,16 @@ default zero" — the same bar, applied here too. feature-local. Only _promote_ something to `shared/` on its _second_, genuinely-identical use across features — the "wait for the second use" rule gates promotion to `shared/`, not the initial extraction. -- `TagsPage` is the reference for _behavior and design_ (search → table → - dialog create/edit → `AlertDialog` delete-confirm, loading/error/empty - states) — not for _anatomy_. A feature with more workflows (Services: +- `CategoriesListPage`/`CategoryEditorDialog` (`features/catalog/presentation/categories/`) + is the reference for _behavior and design_ (search → table → dialog + create/edit → `AlertDialog` delete-confirm, loading/error/empty states) — + not for _anatomy_. Categories' create/edit dialog is routed + (`/categories/new`, `/categories/:id/edit`, docs/adr/012) rather than + toggled by local state; that routing detail is Categories-specific, not a + requirement for every feature. A feature with more workflows (Services: filters + pagination + dirty-tracking + inline-create) needs more files - than Tags does; that's a correctly-sized decomposition, not a deviation. + than Categories does; that's a correctly-sized decomposition, not a + deviation. - Decomposition triggers: multiple independent workflows in one hook/component, several dialogs, distinct state clusters, a prop list a reader can't hold in their head, a type cycle between a controller and @@ -160,7 +167,7 @@ default zero" — the same bar, applied here too. a trigger, and splitting a genuinely cohesive 150-line component to hit a number is not the goal. - `GenericCrudPage` (or any generic entity-agnostic CRUD abstraction) is - prohibited. Tags/Categories/Services each keep their own page, form, and + prohibited. Categories/Services each keep their own page, form, and table — share only behavior that's proven identical (`useDialogTarget`, `useDeleteConfirmation`, `DeleteConfirmationDialog`, `CollectionFeedback`, all in `shared/`), never a config-driven generic @@ -181,7 +188,7 @@ default zero" — the same bar, applied here too. is available for accessibility assertions — the matcher is registered globally in `src/test/setup.ts`. Add it to any new or changed form/page that a screen-reader or keyboard-only user would rely on; see - `TagForm.test.tsx` for the pattern. + `CategoriesRoutes.test.tsx` for the pattern. - A form field wired through `Controller` (not `register()`) needs its rendered component to forward a `ref` to a real, focusable DOM node (`CreatableSingleSelect`/`CreatableMultiSelect` both do) - otherwise @@ -264,9 +271,9 @@ page). The short version: `StatusMessage`, `TextField`/`TextAreaField`, `CenteredScreen`, `FullScreenSpinner`, `CollectionFeedback`, `DeleteConfirmationDialog`) — don't hand-roll markup shadcn or an existing composite already covers. -- `TagsPage`/`TagForm` (`features/catalog/presentation/tags/`) is the - reference implementation for a CRUD list+form page (table + dialog) — - see "Componentization" above for what "reference" means here. +- `CategoriesListPage`/`CategoryEditorDialog` (`features/catalog/presentation/categories/`) + is the reference implementation for a CRUD list+form page (table + dialog) + — see "Componentization" above for what "reference" means here. `AdminLayout` (`app/layouts/`) is the reference for the page shell, including its off-canvas mobile sidebar — new pages don't need their own mobile nav handling. @@ -289,6 +296,7 @@ Copy `.env.example` to `.env.local`. Never commit `.env.local`. - ✅ Tooling, Auth vertical, composition root, presentation shell - ✅ `HttpClient` (`AuthenticatedHttpClient`) — REST features are unblocked - ✅ shadcn/ui design system, dark mode, mobile-responsive `AdminLayout` -- ✅ Tags, Categories, Services (frontend + backend, search/filtering, pagination) +- ✅ Categories, Services (frontend + backend, search/filtering, pagination) +- Tags removed from the frontend (docs/adr/016); backend `Tag`/`/api/v1/tags` retained - ✅ Feature-based physical layout (`app/`, `features/{auth,catalog}/`, `shared/` — ADR 009) - 🔲 Clients → Appointments → Inbox → Dashboard → Settings diff --git a/apps/admin-frontend/docs/API.md b/apps/admin-frontend/docs/API.md index 57cab82..1cce19f 100644 --- a/apps/admin-frontend/docs/API.md +++ b/apps/admin-frontend/docs/API.md @@ -75,9 +75,9 @@ Problem Details, always carrying a machine-readable `code` ```json { "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", - "title": "Já existe uma etiqueta chamada 'VIP'.", + "title": "Já existe uma categoria chamada 'Massagens'.", "status": 409, - "code": "Tag.DuplicateName" + "code": "Category.DuplicateName" } ``` @@ -119,7 +119,7 @@ into application or presentation (docs/adr/007). `shared/presentation/forms/serverFormError.ts`'s `mapApiErrorToForm` turns a caught `AppError` into field-level messages a form applies via react-hook-form's `setError`, reading `AppError.rawFieldErrors`/ -`backendCode` — each form (`ServiceForm`/`CategoryForm`/`TagForm`) exports +`backendCode` — each form (`ServiceForm`/`CategoryForm`) exports its own backend-property → field-name map (e.g. `DurationMinutes` → `durationMinutes`) and a conflict-`code` → field map (e.g. `Service.DuplicateName` → `name`). @@ -140,53 +140,11 @@ real spec says otherwise — don't invent a different shape. ### Tags -Served by **services-service** (`VITE_API_BASE_URL`). Tenant scope comes -from the `X-Tenant-Id` header, verified against the JWT's `tenant_id` -claim. Routes are versioned (`Asp.Versioning.Mvc`, docs/adr/0005) — -omitting the segment falls back to v1, but the frontend always sends it -explicitly. - -| Method | Path | Success | -| -------- | ------------------- | ------------------------------------------- | -| `GET` | `/api/v1/tags` | `200` — `TagDto[]`, ordered by name (asc) | -| `POST` | `/api/v1/tags` | `201` — created `TagDto`, `Location` header | -| `PUT` | `/api/v1/tags/{id}` | `200` — updated `TagDto` | -| `DELETE` | `/api/v1/tags/{id}` | `204` — no body | - -`GET` accepts an optional `search` query param (case-insensitive name -match), e.g. `GET /api/v1/tags?search=vip`. - -`DELETE` fails with `409` (`Tag.InUse`) if the tag is still referenced by -one or more Services. - -`TagDto`: - -```json -{ - "id": "0b6e5b3c-8f4e-4a52-9d0e-1c2a3b4c5d6e", - "name": "VIP", - "color": "#0d9488", - "description": "High-value returning client" -} -``` - -`description` is `null` when unset. Request body for `POST`/`PUT` is the -same shape minus `id` (`description` optional). - -Validation rules (server-enforced, mirror them client-side): - -- `name`: required, trimmed, 1–40 chars, **unique per tenant** - (case-insensitive) → violations: `400` (shape) / `409` (duplicate) -- `color`: required, must be one of the fixed palette below → `400` -- `description`: optional, trimmed, max 200 chars → `400` -- Unknown `{id}` within the tenant → `404` - -Fixed color palette (the only accepted `color` values): - -``` -#0d9488 (teal) #0ea5e9 (sky) #8b5cf6 (violet) #ec4899 (pink) -#ef4444 (red) #f59e0b (amber) #22c55e (green) #64748b (slate) -``` +Removed from the frontend — see `docs/adr/016-remove-tags-frontend.md`. +The backend still serves `/api/v1/tags` (`TagDto` with `id`/`name`/ +`color`/`description`, an 8-color fixed palette, `409 Tag.InUse` on +in-use delete) and `ServiceDto` still embeds a `tags`/`tagIds` field (see +Services below) — this app just no longer builds against any of it. ### Categories @@ -259,7 +217,10 @@ Response envelope (`PagedResult`): } ``` -`TagSummaryDto` (embedded on a `ServiceDto`, a slice of the full Tag): +`TagSummaryDto` (embedded on a `ServiceDto`, a slice of the full Tag) — +still part of the real backend contract even though the frontend Tags +vertical was removed (docs/adr/016-remove-tags-frontend.md); a future +Services UI needs to decide how to handle it: ```json { "id": "0b6e5b3c-8f4e-4a52-9d0e-1c2a3b4c5d6e", "name": "VIP", "color": "#0d9488" } diff --git a/apps/admin-frontend/docs/DOMAIN.md b/apps/admin-frontend/docs/DOMAIN.md index 9bf3f6f..0612af0 100644 --- a/apps/admin-frontend/docs/DOMAIN.md +++ b/apps/admin-frontend/docs/DOMAIN.md @@ -87,9 +87,12 @@ Key fields (confirmed, docs/API.md): booking time (`0–100`) - `categoryId` / `categoryName` — optional Category this service belongs to (`null` when uncategorized) -- `tags` — `TagSummary[]` (`id`/`name`/`color`), a read-only slice of - the Tag catalog attached to this service; managing which tags exist - is the Tags vertical's job, this is just the attachment +- `tags` — the backend still returns a `TagSummary[]` slice (`id`/`name`/ + `color`) on this field, since the backend `Tag` domain was intentionally + kept (docs/adr/016-remove-tags-frontend.md in this app's ADRs). The + frontend has no `Tag` entity or Tags vertical anymore — a future Services + UI needs to decide how to handle this field (e.g. reintroducing a minimal + read-only tag type, or dropping it from the form entirely) Services are **tenant-scoped**. The AI references this list when answering client questions about what's available. @@ -137,21 +140,10 @@ Client history = their list of Appointments under this Business. ## Tag -A tenant-scoped label the business defines to organize its records — -"VIP", "New client", "Allergic to X". In v1 the Tags vertical manages -the tag _catalog_ only; attaching tags to Clients/Conversations ships -with those verticals. - -Key fields: - -- `id` -- `name` — 1–40 chars, trimmed, unique per Business (case-insensitive) -- `color` — one hex value from the fixed 8-color palette (see API.md); - free-form colors are not allowed -- `description` — optional, max 200 chars, guidance on when to use the tag - -Tags are **tenant-scoped**: two businesses can both have a "VIP" tag; -they are unrelated records. +Removed from the frontend domain model — see +`docs/adr/016-remove-tags-frontend.md`. The backend still owns a `Tag` +entity and `/api/v1/tags` endpoints (project-owner decision to retain +them), but this app no longer models or surfaces Tags. --- diff --git a/apps/admin-frontend/docs/STATUS.md b/apps/admin-frontend/docs/STATUS.md index 38de335..3ff9a4a 100644 --- a/apps/admin-frontend/docs/STATUS.md +++ b/apps/admin-frontend/docs/STATUS.md @@ -27,7 +27,7 @@ what's blocked, and what order to build things in. | Local Git hooks | `removed` | Quality gates run explicitly during development and in required CI checks | | `HttpClient` interface + `AuthenticatedHttpClient` | `done` | Single per-request session read (token + tenant id together); converts every failure to `AppError` | | MSW handlers (auth) | `stub` | Auth uses OIDC not REST — no handlers needed | -| MSW handlers (Tags/Categories/Services) | `done` | `tagHandlers.ts`/`categoryHandlers.ts`/`serviceHandlers.ts` | +| MSW handlers (Categories/Services) | `done` | `categoryHandlers.ts`/`serviceHandlers.ts` | | MSW handlers (remaining REST features) | `stub` | Add per-feature as specs arrive (Clients, Appointments, Inbox, Settings) | | shadcn/ui design system (`src/components/ui/`) | `done` | Radix-based, stock "Nova"/neutral theme, unmodified; see ADR 005 | | `ThemeProvider` / `useTheme` / `ThemeToggle` | `done` | Light/dark, defaults to OS preference, persists an override | @@ -66,17 +66,10 @@ what's blocked, and what order to build things in. ### Tags -| Piece | Status | Notes | -| ---------------------------------------- | ------ | -------------------------------------------------------- | -| `Tag` entity | `done` | | -| `TagRepository` interface | `done` | | -| Use cases (List, Create, Update, Delete) | `done` | | -| `ApiTagRepository` + `tagMapper` | `done` | | -| `useTags` hook | `done` | | -| `TagsPage` + nav entry | `done` | Table list, dialog create/edit form, delete with confirm | -| Backend (services-service `/api/tags`) | `done` | First real vertical in services-service | - -**Dependency:** none. First REST vertical built end-to-end (backend + frontend). +Removed from the frontend — see `docs/adr/016-remove-tags-frontend.md`. The +backend `Tag` domain entity and `/api/v1/tags` endpoints are intentionally +retained (project-owner decision), just no longer surfaced or consumed by +this app. --- @@ -92,8 +85,11 @@ what's blocked, and what order to build things in. | `ServicesPage` + `ServiceForm` + nav entry | `done` | Table list, dialog create/edit form (category `Select`, tag toggle grid), delete with confirm | | Backend (services-service `/api/v1/services`) | `done` | Search/filter/pagination added; see docs/adr/0012 for the latest validation/handler shape | -**Dependency:** none structurally — depends on Categories and Tags for the -create/edit form's pickers, both already built. +**Dependency:** none structurally — depends on Categories for the create/edit +form's category picker. The backend `ServiceDto` still has `tags`/`tagIds` +(docs/API.md) since the backend Tag domain was kept; the frontend has no +Tag entity or picker anymore (docs/adr/016), so a tag-selection UI needs to +be designed when this form is actually built. --- @@ -185,16 +181,18 @@ create/edit form's pickers, both already built. ``` 1. HttpClient (unblocks all REST features) [done] -2. Tags (no dependencies, first REST vertical) [done] -3. Categories (no dependencies, simplest CRUD) [done] -4. Services (depends on Categories + Tags for its form pickers) [done] -5. Clients (simple CRUD) -6. Appointments (depends on Services for create form) -7. Inbox (depends on Clients) -8. Dashboard (depends on Appointments + Inbox for overview data) -9. Settings (independent, can be done any time after HttpClient) +2. Categories (no dependencies, simplest CRUD) [done] +3. Services (depends on Categories for its form pickers) [done] +4. Clients (simple CRUD) +5. Appointments (depends on Services for create form) +6. Inbox (depends on Clients) +7. Dashboard (depends on Appointments + Inbox for overview data) +8. Settings (independent, can be done any time after HttpClient) ``` +Tags was previously vertical #2 here (first REST vertical, no dependencies) +but was removed from the frontend — see `docs/adr/016-remove-tags-frontend.md`. + --- ## Test counts @@ -257,21 +255,19 @@ services-service, or Postgres needs to be running. Covered so far: navigation, and logout (mocking the OIDC discovery document + end-session redirect, not just a REST endpoint, so the real `OidcAuthRepository` runs unmodified). -- Tags: full create → edit → delete flow through the real - `HttpClient`/repository/mapper stack (not a faked `AppContainer`, unlike - the unit tests). -- Tags list: a failed refetch keeps showing the last known-good data with a - retry action, and retry recovers. - -**Deliberately not duplicated here** (already covered at the unit level, -listed so the gap is explicit rather than silent): the OIDC callback's -idempotency under `StrictMode` (`CallbackPage.test.tsx`, -`HandleAuthCallback.test.ts`), tenant-switch races in `useAsync`/ -`useCreateInline` (their own dedicated test files), and cross-tenant/ -cross-session visual bleed (`TenantBoundary.test.tsx`). Categories/Services -CRUD aren't E2E-tested separately — Tags is the reference flow and the -other two verticals share the same `useAsync`/repository/mapper -machinery already exercised there and at the unit level. + **Deliberately not duplicated here** (already covered at the unit level, + listed so the gap is explicit rather than silent): the OIDC callback's + idempotency under `StrictMode` (`CallbackPage.test.tsx`, + `HandleAuthCallback.test.ts`), tenant-switch races in `useAsync`/ + `useCreateInline` (their own dedicated test files), and cross-tenant/ + cross-session visual bleed (`TenantBoundary.test.tsx`). Categories/Services + CRUD aren't E2E-tested separately — they share the same `useAsync`/ + repository/mapper machinery already exercised at the unit level. A full + create → edit → delete E2E flow (previously `tags-crud.spec.ts`) and a + failed-refetch-retry flow (previously `tags-list-retry.spec.ts`) existed + for Tags and were removed along with the vertical + (docs/adr/016-remove-tags-frontend.md) — no other vertical has picked up + that full-CRUD E2E coverage yet. **Not yet wired into CI** (`.github/workflows/frontend-ci.yml`): doing so would need `npx playwright install --with-deps chromium` added as a step @@ -295,7 +291,7 @@ authentication transition and feedback work — captured from | `ServicesPage-*.js` | 96.24 kB | 30.57 kB | | `index-*.css` | 65.11 kB | 10.99 kB | -All other route chunks (Categories/Tags pages and forms, stub pages) are +All other route chunks (Categories pages and forms, stub pages) are under 10 kB raw each. Vite now emits the OIDC/auth dependency graph as its own shared chunk; `index` + `auth` remain approximately the same combined size as the previous monolithic main entry. The feedback UI added no heavy diff --git a/apps/admin-frontend/docs/adr/016-remove-tags-frontend.md b/apps/admin-frontend/docs/adr/016-remove-tags-frontend.md new file mode 100644 index 0000000..06e8c5f --- /dev/null +++ b/apps/admin-frontend/docs/adr/016-remove-tags-frontend.md @@ -0,0 +1,64 @@ +# ADR 016 — Tags removed from the frontend; backend Tag API retained + +**Status:** Accepted + +## Decision + +The entire Tags vertical is removed from `apps/admin-frontend`: + +- Domain (`Tag` entity, `InvalidTagError`), application + (`TagRepository`, its fake), infrastructure (`ApiTagRepository`, + `tagMapper`), and presentation (`TagsPage`, `TagForm`, `TagsTable`, + `TagEditorDialog`, `useTags`/`useTagEditor`/`useTagsPage`) are all + deleted. +- The `/tags` route, the "Etiquetas" sidebar entry, the `catalog` facade's + `*Tag` members, the `tagHandlers` MSW mocks, and the two Tags E2E specs + (`tags-crud.spec.ts`, `tags-list-retry.spec.ts`) are all removed. +- `CategoriesListPage`/`CategoryEditorDialog` becomes this app's reference + implementation for a CRUD list+form page, replacing the role `TagsPage`/ + `TagForm` played (see `AGENTS.md`'s Componentization and Design language + sections). + +**The backend `Tag` domain entity and `/api/v1/tags` endpoints are +intentionally kept, unchanged** — this is a frontend-only removal, an +explicit project-owner decision. `Service`'s backend-side many-to-many +relationship to `Tag` (the `ServiceTags` join table, `TagIds` on +create/update, the `tagId` list filter, `TagSummary` on `ServiceDto`) is +untouched. `docs/API.md` and `docs/DOMAIN.md` note this explicitly so a +reader doesn't conclude the backend contract changed to match. + +## Rationale + +An earlier attempt at this removal also started stripping `Tag` out of the +backend's `Service` aggregate (a real EF Core many-to-many relationship, +not just documentation) and was interrupted mid-way, leaving a commit with +a broken backend build. Given the choice of finishing that backend removal +or reverting it, the project owner chose to keep the backend Tag domain +model and API surface as-is and scope this change to the frontend only. +This avoids a backend schema migration, a rewrite of `Service`'s +relationship-loading/validation code, and a governance-script update +(`scripts/architecture_guard.py`'s `check_database_boundary_configuration` +hardcodes the `ServiceTags` EF configuration) for a UI feature that's +simply not needed right now. + +## Consequences + +- `apps/admin-frontend/AGENTS.md`, `docs/STATUS.md`, `docs/DOMAIN.md`, + `docs/API.md`, `.agent.md`, `eslint.config.js`, and `.env.example` are + updated to stop describing Tags as a built frontend feature, and to + flag that the backend still returns `tags`/`tagIds` on `ServiceDto` + even though the frontend has no `Tag` type to represent it. +- `agent-skills/agenza-frontend-feature` (canonical, synced to + `.claude/skills/`/`.agents/skills/`) no longer uses `TagsPage`/`TagForm` + as its worked example; it uses Categories instead. +- Historical ADRs (007–011, 014) that used Tags as a worked example when + documenting an already-made decision are left as-is — they describe + what was true at the time, not current reality. +- If Services' create/edit form is built later, it inherits a real + backend `tags`/`tagIds` field with no frontend `Tag` type to back it - + whoever builds that form needs to decide whether to reintroduce a + minimal read-only tag type, call the backend's `/api/v1/tags` directly + without a full vertical, or drop tag selection from the form. Not + decided here. +- Backend test/build/governance gates are unaffected — no backend files + changed as part of this ADR. diff --git a/apps/admin-frontend/e2e/authenticated-shell.spec.ts b/apps/admin-frontend/e2e/authenticated-shell.spec.ts index 087b310..b453fc5 100644 --- a/apps/admin-frontend/e2e/authenticated-shell.spec.ts +++ b/apps/admin-frontend/e2e/authenticated-shell.spec.ts @@ -11,21 +11,21 @@ test.describe('authenticated shell', () => { await expect(page).toHaveURL(/\/dashboard$/) await expect(page.getByRole('heading', { name: 'Painel' })).toBeVisible() - await expect(page.getByRole('link', { name: 'Etiquetas' })).toBeVisible() + await expect(page.getByRole('link', { name: 'Categorias' })).toBeVisible() await expect(page.getByText('Clínica Bem-Estar')).toBeVisible() }) test('navigates to a catalog page via the sidebar', async ({ page }) => { await page.route( - url => url.pathname.startsWith('/api/v1/tags'), + url => url.pathname.startsWith('/api/v1/categories'), route => route.fulfill({ json: [] }), ) await page.goto('/dashboard') - await page.getByRole('link', { name: 'Etiquetas' }).click() + await page.getByRole('link', { name: 'Categorias' }).click() - await expect(page).toHaveURL(/\/tags$/) - await expect(page.getByRole('heading', { name: 'Etiquetas' })).toBeVisible() + await expect(page).toHaveURL(/\/categories$/) + await expect(page.getByRole('heading', { name: 'Categorias' })).toBeVisible() }) test('logs out and automatically opens a fresh login', async ({ page }) => { diff --git a/apps/admin-frontend/e2e/tags-crud.spec.ts b/apps/admin-frontend/e2e/tags-crud.spec.ts deleted file mode 100644 index d8c85d6..0000000 --- a/apps/admin-frontend/e2e/tags-crud.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { test, expect } from '@playwright/test' -import { injectAuthenticatedSession } from './support/session' - -interface TagRecord { - id: string - name: string - color: string - description: string | null -} - -interface TagWriteBody { - name: string - color: string - description?: string | null -} - -test.describe('tags catalog - create, edit, delete', () => { - test.beforeEach(async ({ page }) => { - await injectAuthenticatedSession(page) - - let tags: TagRecord[] = [] - let nextId = 1 - - // A tiny in-memory fake of the /api/v1/tags REST surface - exercises the - // real HttpClient/repository/mapper stack end to end (unlike the unit - // tests, which fake AppContainer above that layer), without needing - // services-service or a database running. - await page.route( - url => url.pathname.startsWith('/api/v1/tags'), - async route => { - const request = route.request() - const method = request.method() - const path = new URL(request.url()).pathname - - if (method === 'GET') { - await route.fulfill({ json: tags }) - return - } - - if (method === 'POST') { - const body = request.postDataJSON() as TagWriteBody - const created: TagRecord = { - id: `tag-${String(nextId)}`, - name: body.name, - color: body.color, - description: body.description ?? null, - } - nextId += 1 - tags = [...tags, created] - await route.fulfill({ json: created }) - return - } - - if (method === 'PUT') { - const id = path.split('/').pop() - const body = request.postDataJSON() as TagWriteBody - tags = tags.map(tag => - tag.id === id - ? { - ...tag, - name: body.name, - color: body.color, - description: body.description ?? null, - } - : tag, - ) - const updated = tags.find(tag => tag.id === id) - await route.fulfill({ json: updated }) - return - } - - if (method === 'DELETE') { - const id = path.split('/').pop() - tags = tags.filter(tag => tag.id !== id) - await route.fulfill({ status: 204 }) - return - } - - await route.continue() - }, - ) - }) - - test('creates, edits, and deletes a tag end to end', async ({ page }) => { - await page.goto('/tags') - await expect(page.getByText('Nenhuma etiqueta ainda. Crie uma para começar.')).toBeVisible() - - await page.getByRole('button', { name: 'Nova etiqueta' }).click() - await page.getByLabel('Nome', { exact: true }).fill('Promoção de verão') - await page.getByRole('button', { name: 'Criar etiqueta' }).click() - - await expect(page.getByRole('cell', { name: 'Promoção de verão' })).toBeVisible() - - await page.getByRole('button', { name: 'Editar' }).click() - await page.getByLabel('Nome', { exact: true }).fill('Promoção de verão (editado)') - await page.getByRole('button', { name: 'Salvar alterações' }).click() - - await expect(page.getByRole('cell', { name: 'Promoção de verão (editado)' })).toBeVisible() - - await page.getByRole('button', { name: 'Excluir' }).click() - await page.getByRole('alertdialog').getByRole('button', { name: 'Excluir' }).click() - - await expect(page.getByText('Nenhuma etiqueta ainda. Crie uma para começar.')).toBeVisible() - }) -}) diff --git a/apps/admin-frontend/e2e/tags-list-retry.spec.ts b/apps/admin-frontend/e2e/tags-list-retry.spec.ts deleted file mode 100644 index 9383a6e..0000000 --- a/apps/admin-frontend/e2e/tags-list-retry.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { test, expect } from '@playwright/test' -import { injectAuthenticatedSession } from './support/session' - -const EXISTING_TAG = { id: 'tag-1', name: 'VIP', color: '#0d9488', description: null } - -test.describe('tags list - failed refetch and retry', () => { - test.beforeEach(async ({ page }) => { - await injectAuthenticatedSession(page) - }) - - test('keeps showing the last known list on a failed refetch, then recovers on retry', async ({ - page, - }) => { - let requestCount = 0 - await page.route( - url => url.pathname.startsWith('/api/v1/tags'), - route => { - requestCount += 1 - // 1st call: initial load. 2nd call: the search-triggered refetch this - // test deliberately fails. 3rd call: the manual retry, which succeeds. - if (requestCount === 2) { - return route.fulfill({ status: 500, json: { title: 'Erro interno' } }) - } - return route.fulfill({ json: [EXISTING_TAG] }) - }, - ) - - await page.goto('/tags') - await expect(page.getByRole('cell', { name: 'VIP' })).toBeVisible() - - await page.getByRole('searchbox', { name: 'Buscar etiqueta por nome' }).fill('vip') - - await expect(page.getByText(/Não foi possível atualizar a lista de etiquetas/)).toBeVisible() - // The mutation that matters (loading the list) already succeeded once - - // stale data stays on screen instead of being replaced by a blank error. - await expect(page.getByRole('cell', { name: 'VIP' })).toBeVisible() - - await page.getByRole('button', { name: 'Tentar novamente' }).click() - - await expect( - page.getByText(/Não foi possível atualizar a lista de etiquetas/), - ).not.toBeVisible() - await expect(page.getByRole('cell', { name: 'VIP' })).toBeVisible() - expect(requestCount).toBe(3) - }) -}) diff --git a/apps/admin-frontend/eslint.config.js b/apps/admin-frontend/eslint.config.js index 9fe5666..f5c75b8 100644 --- a/apps/admin-frontend/eslint.config.js +++ b/apps/admin-frontend/eslint.config.js @@ -41,8 +41,8 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/explicit-function-return-type': ['warn', { allowExpressions: true }], // A repository port's method signature is the interface contract - // (e.g. every TagRepository method takes tenantContext structurally, - // per admin-feature-vertical skill); a specific adapter - like an + // (e.g. every CategoryRepository method takes tenantContext structurally, + // per agenza-frontend-feature skill); a specific adapter - like an // HTTP one where the tenant travels in the JWT instead - may not // need to read that parameter. Leading underscore marks that // deliberately, distinct from a genuinely forgotten unused variable. diff --git a/apps/admin-frontend/src/app/composition/container.test.ts b/apps/admin-frontend/src/app/composition/container.test.ts index f4b9342..8d794db 100644 --- a/apps/admin-frontend/src/app/composition/container.test.ts +++ b/apps/admin-frontend/src/app/composition/container.test.ts @@ -34,7 +34,7 @@ describe('createAppContainer', () => { it('wires the catalog facade to the concrete repository', () => { const container = createAppContainer() - expect(container.catalog.listTags.execute).toBeTypeOf('function') + expect(container.catalog.listCategories.execute).toBeTypeOf('function') }) it('does not expose a repository or an HttpClient on the container', () => { @@ -44,7 +44,7 @@ describe('createAppContainer', () => { // guards against a future change accidentally re-adding one. expect(container).not.toHaveProperty('httpClient') expect(container).not.toHaveProperty('authRepository') - expect(container).not.toHaveProperty('tagRepository') + expect(container).not.toHaveProperty('categoryRepository') }) it('wires the same sessionEvents instance into the http client, so a 401/no-token reaches subscribers', async () => { @@ -56,7 +56,7 @@ describe('createAppContainer', () => { // access token - it goes through the private httpClient built with this // exact sessionEvents instance as its notifier, proving they're wired // together end to end rather than just both present on the container. - await container.catalog.listTags.execute({}) + await container.catalog.listCategories.execute({}) expect(listener).toHaveBeenCalledTimes(1) }) diff --git a/apps/admin-frontend/src/app/composition/container.ts b/apps/admin-frontend/src/app/composition/container.ts index 92119c8..20e6474 100644 --- a/apps/admin-frontend/src/app/composition/container.ts +++ b/apps/admin-frontend/src/app/composition/container.ts @@ -12,12 +12,7 @@ import { GetCurrentSession, Logout, } from '@/features/auth' -import { - ApiTagRepository, - ApiCategoryRepository, - type TagRepository, - type CategoryRepository, -} from '@/features/catalog' +import { ApiCategoryRepository, type CategoryRepository } from '@/features/catalog' // Each entry is the *shape* of a use case (Pick), not the // concrete class - makes a plain `{ execute: vi.fn(...) }` a valid, fully @@ -31,15 +26,10 @@ export interface AuthFacade { sessionEvents: SessionEventBus } -/** Tags and Categories collaborate in the same business context. Each entry's - * execute signature mirrors the matching repository method directly - there's - * no orchestration between the facade and the repository, so no intermediate - * use-case class is worth the indirection. */ +/** Each entry's execute signature mirrors the matching repository method + * directly - there's no orchestration between the facade and the repository, + * so no intermediate use-case class is worth the indirection. */ export interface CatalogFacade { - listTags: { execute: TagRepository['listAll'] } - createTag: { execute: TagRepository['create'] } - updateTag: { execute: TagRepository['update'] } - deleteTag: { execute: TagRepository['delete'] } listCategories: { execute: CategoryRepository['listAll'] } getCategory: { execute: CategoryRepository['getById'] } createCategory: { execute: CategoryRepository['create'] } @@ -79,7 +69,6 @@ export function createAppContainer(): AppContainer { sessionEvents, ) - const tagRepository: TagRepository = new ApiTagRepository(httpClient) const categoryRepository: CategoryRepository = new ApiCategoryRepository(httpClient) return { @@ -91,10 +80,6 @@ export function createAppContainer(): AppContainer { sessionEvents, }, catalog: { - listTags: { execute: options => tagRepository.listAll(options) }, - createTag: { execute: input => tagRepository.create(input) }, - updateTag: { execute: (id, input) => tagRepository.update(id, input) }, - deleteTag: { execute: id => tagRepository.delete(id) }, listCategories: { execute: options => categoryRepository.listAll(options) }, getCategory: { execute: id => categoryRepository.getById(id) }, createCategory: { execute: input => categoryRepository.create(input) }, diff --git a/apps/admin-frontend/src/app/layouts/AdminLayout.test.tsx b/apps/admin-frontend/src/app/layouts/AdminLayout.test.tsx index f03a720..03ca044 100644 --- a/apps/admin-frontend/src/app/layouts/AdminLayout.test.tsx +++ b/apps/admin-frontend/src/app/layouts/AdminLayout.test.tsx @@ -75,7 +75,6 @@ describe('AdminLayout', () => { 'Serviços', 'Clientes', 'Caixa de entrada', - 'Etiquetas', 'Configurações', ]) { expect(screen.getByRole('link', { name: label })).toBeInTheDocument() diff --git a/apps/admin-frontend/src/app/layouts/AdminLayout.tsx b/apps/admin-frontend/src/app/layouts/AdminLayout.tsx index 112e8c1..6086796 100644 --- a/apps/admin-frontend/src/app/layouts/AdminLayout.tsx +++ b/apps/admin-frontend/src/app/layouts/AdminLayout.tsx @@ -12,7 +12,6 @@ import { Menu, Settings as SettingsIcon, Sparkles, - Tag as TagIcon, Users, } from 'lucide-react' import { useAuth, useAuthenticatedTenant, TenantBoundary } from '@/features/auth' @@ -33,7 +32,6 @@ const NAV_ITEMS: readonly NavItem[] = [ { label: 'Categorias', to: '/categories', icon: LayoutGrid }, { label: 'Clientes', to: '/clients', icon: Users }, { label: 'Caixa de entrada', to: '/inbox', icon: InboxIcon }, - { label: 'Etiquetas', to: '/tags', icon: TagIcon }, { label: 'Configurações', to: '/settings', icon: SettingsIcon }, ] diff --git a/apps/admin-frontend/src/app/routes/router.tsx b/apps/admin-frontend/src/app/routes/router.tsx index 2a4fe90..dadfe9c 100644 --- a/apps/admin-frontend/src/app/routes/router.tsx +++ b/apps/admin-frontend/src/app/routes/router.tsx @@ -38,15 +38,6 @@ const CategoryEditorDialog = lazy(() => m => ({ default: m.CategoryEditorDialog }), ), ) -const TagsPage = lazy(() => - import('@/features/catalog/presentation/tags/TagsPage').then(m => ({ default: m.TagsPage })), -) -const TagEditorDialog = lazy(() => - import('@/features/catalog/presentation/tags/pages/TagEditorDialog').then(m => ({ - default: m.TagEditorDialog, - })), -) - function withSuspense(element: ReactElement): ReactElement { return }>{element} } @@ -87,14 +78,6 @@ export const router = createBrowserRouter([ }, { path: 'clients', element: withSuspense() }, { path: 'inbox', element: withSuspense() }, - { - path: 'tags', - element: withSuspense(), - children: [ - { path: 'new', element: withSuspense() }, - { path: ':id/edit', element: withSuspense() }, - ], - }, { path: 'services', element: withSuspense() }, { path: 'settings', element: withSuspense() }, ], diff --git a/apps/admin-frontend/src/features/catalog/application/repositories/TagRepository.ts b/apps/admin-frontend/src/features/catalog/application/repositories/TagRepository.ts deleted file mode 100644 index 1ddc89d..0000000 --- a/apps/admin-frontend/src/features/catalog/application/repositories/TagRepository.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Tag } from '@/features/catalog/domain/entities/Tag' -import type { AppError } from '@/shared/application/AppError' -import type { Result } from '@/shared/application/Result' - -export interface CreateTagInput { - name: string - color: string - description?: string -} - -export interface UpdateTagInput { - name: string - color: string - description?: string -} - -export interface ListAllTagsOptions { - search?: string -} - -export interface TagRepository { - listAll(options?: ListAllTagsOptions): Promise> - create(input: CreateTagInput): Promise> - update(id: string, input: UpdateTagInput): Promise> - delete(id: string): Promise> -} diff --git a/apps/admin-frontend/src/features/catalog/application/test-helpers/createFakeTagRepository.ts b/apps/admin-frontend/src/features/catalog/application/test-helpers/createFakeTagRepository.ts deleted file mode 100644 index 7258d1d..0000000 --- a/apps/admin-frontend/src/features/catalog/application/test-helpers/createFakeTagRepository.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { TagRepository } from '@/features/catalog/application/repositories/TagRepository' -import { AppError } from '@/shared/application/AppError' -import { failure, success } from '@/shared/application/Result' - -const NOT_IMPLEMENTED = new AppError({ - code: 'unexpected', - message: 'not implemented in this fake', - retryable: false, -}) - -export function createFakeTagRepository(overrides: Partial = {}): TagRepository { - return { - listAll: () => Promise.resolve(success([])), - create: () => Promise.resolve(failure(NOT_IMPLEMENTED)), - update: () => Promise.resolve(failure(NOT_IMPLEMENTED)), - delete: () => Promise.resolve(success(undefined)), - ...overrides, - } -} diff --git a/apps/admin-frontend/src/features/catalog/domain/entities/Tag.test.ts b/apps/admin-frontend/src/features/catalog/domain/entities/Tag.test.ts deleted file mode 100644 index 7bf5f90..0000000 --- a/apps/admin-frontend/src/features/catalog/domain/entities/Tag.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { Tag, TAG_COLOR_PALETTE } from '@/features/catalog/domain/entities/Tag' -import { InvalidTagError } from '@/features/catalog/domain/errors/InvalidTagError' - -describe('Tag', () => { - it('creates a tag with valid values', () => { - const result = Tag.create({ - id: 'tag-1', - name: 'VIP', - color: '#0d9488', - description: 'High-value client', - }) - - expect(result.success).toBe(true) - if (!result.success) return - expect(result.value.id).toBe('tag-1') - expect(result.value.name).toBe('VIP') - expect(result.value.color).toBe('#0d9488') - expect(result.value.description).toBe('High-value client') - }) - - it('creates a tag without a description', () => { - const result = Tag.create({ id: 'tag-1', name: 'VIP', color: '#0d9488' }) - - expect(result.success).toBe(true) - if (!result.success) return - expect(result.value.description).toBeUndefined() - }) - - it('fails when the id is empty', () => { - const result = Tag.create({ id: '', name: 'VIP', color: '#0d9488' }) - - expect(result.success).toBe(false) - if (result.success) return - expect(result.error).toBeInstanceOf(InvalidTagError) - }) - - it('fails when the name is empty', () => { - const result = Tag.create({ id: 'tag-1', name: ' ', color: '#0d9488' }) - - expect(result.success).toBe(false) - if (result.success) return - expect(result.error).toBeInstanceOf(InvalidTagError) - }) - - it('fails when the name is over 40 characters', () => { - const name = 'x'.repeat(41) - - const result = Tag.create({ id: 'tag-1', name, color: '#0d9488' }) - - expect(result.success).toBe(false) - if (result.success) return - expect(result.error).toBeInstanceOf(InvalidTagError) - }) - - it('fails when the color is not in the fixed palette', () => { - const result = Tag.create({ id: 'tag-1', name: 'VIP', color: '#123456' }) - - expect(result.success).toBe(false) - if (result.success) return - expect(result.error).toBeInstanceOf(InvalidTagError) - }) - - it('fails when the description is over 200 characters', () => { - const description = 'x'.repeat(201) - - const result = Tag.create({ id: 'tag-1', name: 'VIP', color: '#0d9488', description }) - - expect(result.success).toBe(false) - if (result.success) return - expect(result.error).toBeInstanceOf(InvalidTagError) - }) - - it('exposes the fixed color palette', () => { - expect(TAG_COLOR_PALETTE).toHaveLength(8) - expect(TAG_COLOR_PALETTE).toContain('#0d9488') - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/domain/entities/Tag.ts b/apps/admin-frontend/src/features/catalog/domain/entities/Tag.ts deleted file mode 100644 index 640e08b..0000000 --- a/apps/admin-frontend/src/features/catalog/domain/entities/Tag.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { InvalidTagError } from '@/features/catalog/domain/errors/InvalidTagError' -import { failure, success, type Result } from '@/shared/application/Result' - -/** The only accepted `color` values (docs/API.md) - keeps tags visually consistent. */ -export const TAG_COLOR_PALETTE = [ - '#0d9488', // teal - '#0ea5e9', // sky - '#8b5cf6', // violet - '#ec4899', // pink - '#ef4444', // red - '#f59e0b', // amber - '#22c55e', // green - '#64748b', // slate -] as const - -export type TagColor = (typeof TAG_COLOR_PALETTE)[number] - -interface CreateTagInput { - id: string - name: string - color: string - description?: string -} - -/** A tenant-scoped label the business defines to organize its records (docs/DOMAIN.md "Tag"). */ -export class Tag { - readonly id: string - readonly name: string - readonly color: TagColor - readonly description?: string - - private constructor(id: string, name: string, color: TagColor, description?: string) { - this.id = id - this.name = name - this.color = color - if (description !== undefined) { - this.description = description - } - } - - static create(input: CreateTagInput): Result { - if (input.id.trim().length === 0) { - return failure(new InvalidTagError('O id da etiqueta não pode estar vazio')) - } - - const name = input.name.trim() - if (name.length === 0 || name.length > 40) { - return failure(new InvalidTagError('O nome da etiqueta deve ter entre 1 e 40 caracteres')) - } - - if (!isTagColor(input.color)) { - return failure( - new InvalidTagError( - `A cor da etiqueta deve ser uma das seguintes: ${TAG_COLOR_PALETTE.join(', ')}`, - ), - ) - } - - const description = input.description?.trim() - if (description !== undefined && description.length > 200) { - return failure( - new InvalidTagError('A descrição da etiqueta deve ter no máximo 200 caracteres'), - ) - } - - return success( - new Tag(input.id, name, input.color, description !== '' ? description : undefined), - ) - } -} - -function isTagColor(value: string): value is TagColor { - return (TAG_COLOR_PALETTE as readonly string[]).includes(value) -} diff --git a/apps/admin-frontend/src/features/catalog/domain/errors/InvalidTagError.ts b/apps/admin-frontend/src/features/catalog/domain/errors/InvalidTagError.ts deleted file mode 100644 index c6e7035..0000000 --- a/apps/admin-frontend/src/features/catalog/domain/errors/InvalidTagError.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { DomainError } from '@/shared/domain/DomainError' - -export class InvalidTagError extends DomainError {} diff --git a/apps/admin-frontend/src/features/catalog/index.ts b/apps/admin-frontend/src/features/catalog/index.ts index c12575e..05ce699 100644 --- a/apps/admin-frontend/src/features/catalog/index.ts +++ b/apps/admin-frontend/src/features/catalog/index.ts @@ -1,13 +1,11 @@ // Public API of the catalog feature (ADR 009) - the only path other features -// and app/ may import catalog internals through. TagsPage/CategoriesPage are -// deliberately NOT re-exported here: app/routes/router.tsx lazy-loads them -// by their own module path so Vite keeps each on its own chunk - importing -// them through this barrel would bundle them together and defeat that +// and app/ may import catalog internals through. CategoriesPage is +// deliberately NOT re-exported here: app/routes/router.tsx lazy-loads it +// by its own module path so Vite keeps it on its own chunk - importing +// it through this barrel would bundle it with other pages and defeat that // code-splitting. -export type { TagRepository } from './application/repositories/TagRepository' export type { CategoryRepository } from './application/repositories/CategoryRepository' // Composition-root-only wiring (docs/adr/008) - not for use outside app/composition. -export { ApiTagRepository } from './infrastructure/repositories/ApiTagRepository' export { ApiCategoryRepository } from './infrastructure/repositories/ApiCategoryRepository' diff --git a/apps/admin-frontend/src/features/catalog/infrastructure/mappers/tagMapper.test.ts b/apps/admin-frontend/src/features/catalog/infrastructure/mappers/tagMapper.test.ts deleted file mode 100644 index 1cdbf3f..0000000 --- a/apps/admin-frontend/src/features/catalog/infrastructure/mappers/tagMapper.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - mapTagDtoToDomain, - decodeTagDto, - decodeTagDtoArray, -} from '@/features/catalog/infrastructure/mappers/tagMapper' - -describe('mapTagDtoToDomain', () => { - it('maps every field from the DTO', () => { - const result = mapTagDtoToDomain({ - id: 'tag-1', - name: 'VIP', - color: '#0d9488', - description: 'High-value client', - }) - - expect(result.success).toBe(true) - if (!result.success) return - expect(result.value.id).toBe('tag-1') - expect(result.value.name).toBe('VIP') - expect(result.value.color).toBe('#0d9488') - expect(result.value.description).toBe('High-value client') - }) - - it('maps a null description to undefined', () => { - const result = mapTagDtoToDomain({ - id: 'tag-1', - name: 'VIP', - color: '#0d9488', - description: null, - }) - - expect(result.success).toBe(true) - if (!result.success) return - expect(result.value.description).toBeUndefined() - }) - - it('maps the domain validation failure for an invalid color to a curated AppError', () => { - const result = mapTagDtoToDomain({ - id: 'tag-1', - name: 'VIP', - color: '#123456', - description: null, - }) - - expect(result.success).toBe(false) - if (result.success) return - expect(result.error.code).toBe('unexpected') - }) -}) - -describe('decodeTagDto', () => { - it('accepts a well-formed payload', () => { - const dto = { id: 'tag-1', name: 'VIP', color: '#0d9488', description: null } - - expect(decodeTagDto(dto)).toEqual(dto) - }) - - it('rejects a payload missing a required property', () => { - expect(() => decodeTagDto({ id: 'tag-1', color: '#0d9488', description: null })).toThrow() - }) - - it('rejects a payload with a wrong-typed property', () => { - expect(() => - decodeTagDto({ id: 'tag-1', name: 42, color: '#0d9488', description: null }), - ).toThrow() - }) - - it('rejects a non-object payload', () => { - expect(() => decodeTagDto('not an object')).toThrow() - expect(() => decodeTagDto(null)).toThrow() - expect(() => decodeTagDto(undefined)).toThrow() - }) -}) - -describe('decodeTagDtoArray', () => { - it('accepts a well-formed array', () => { - const dtos = [{ id: 'tag-1', name: 'VIP', color: '#0d9488', description: null }] - - expect(decodeTagDtoArray(dtos)).toEqual(dtos) - }) - - it('rejects a non-array payload', () => { - expect(() => decodeTagDtoArray({ id: 'tag-1' })).toThrow() - }) - - it('rejects an array containing a malformed element', () => { - expect(() => - decodeTagDtoArray([{ id: 'tag-1', name: 'VIP', color: '#0d9488', description: null }, {}]), - ).toThrow() - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/infrastructure/mappers/tagMapper.ts b/apps/admin-frontend/src/features/catalog/infrastructure/mappers/tagMapper.ts deleted file mode 100644 index f03a7d4..0000000 --- a/apps/admin-frontend/src/features/catalog/infrastructure/mappers/tagMapper.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Tag } from '@/features/catalog/domain/entities/Tag' -import type { components } from '@/features/catalog/infrastructure/generated/services-api' -import type { AppError } from '@/shared/application/AppError' -import { failure, type Result } from '@/shared/application/Result' -import { malformedResponseError } from '@/shared/infrastructure/http/malformedResponseError' - -/** The TagDto shape - generated from the live OpenAPI contract, not - * hand-maintained (see src/features/catalog/infrastructure/generated/services-api.d.ts). */ -export type TagDto = components['schemas']['TagResponse'] - -function isTagDto(value: unknown): value is TagDto { - if (typeof value !== 'object' || value === null) { - return false - } - const record = value as Record - return ( - typeof record.id === 'string' && - typeof record.name === 'string' && - typeof record.color === 'string' && - (record.description === null || typeof record.description === 'string') - ) -} - -/** Validates an untrusted response body as a TagDto before any mapper trusts its shape (docs/adr/011). */ -export function decodeTagDto(payload: unknown): TagDto { - if (!isTagDto(payload)) { - throw new Error('Malformed tag payload received from the API') - } - return payload -} - -/** Same as decodeTagDto, for the GET /api/v1/tags list response. */ -export function decodeTagDtoArray(payload: unknown): TagDto[] { - if (!Array.isArray(payload) || !payload.every(isTagDto)) { - throw new Error('Malformed tag list payload received from the API') - } - return payload -} - -export function mapTagDtoToDomain(dto: TagDto): Result { - const result = Tag.create({ - id: dto.id, - name: dto.name, - color: dto.color, - ...(dto.description !== null ? { description: dto.description } : {}), - }) - return result.success ? result : failure(malformedResponseError()) -} diff --git a/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiTagRepository.test.ts b/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiTagRepository.test.ts deleted file mode 100644 index 71a212b..0000000 --- a/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiTagRepository.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { http, HttpResponse } from 'msw' -import { server } from '@/test/mocks/server' -import { ApiTagRepository } from '@/features/catalog/infrastructure/repositories/ApiTagRepository' -import { AuthenticatedHttpClient } from '@/shared/infrastructure/http/AuthenticatedHttpClient' -import { tagFixture } from '@/test/mocks/handlers/tagHandlers' - -const baseUrl = 'https://api.test' - -function buildRepository(): ApiTagRepository { - const httpClient = new AuthenticatedHttpClient(baseUrl, () => - Promise.resolve({ accessToken: 'token-123', tenantId: 'tenant-123' }), - ) - return new ApiTagRepository(httpClient) -} - -describe('ApiTagRepository', () => { - it('lists tags mapped to domain entities', async () => { - const repository = buildRepository() - - const result = await repository.listAll() - - expect(result.success).toBe(true) - if (!result.success) return - expect(result.value).toHaveLength(1) - expect(result.value[0]?.id).toBe(tagFixture.id) - expect(result.value[0]?.name).toBe(tagFixture.name) - }) - - it('sends the search term as a query parameter', async () => { - server.use( - http.get(`${baseUrl}/api/v1/tags`, ({ request }) => { - expect(new URL(request.url).searchParams.get('search')).toBe('vip') - return HttpResponse.json([tagFixture]) - }), - ) - const repository = buildRepository() - - await repository.listAll({ search: 'vip' }) - }) - - it('creates a tag, sending an omitted description as explicit null', async () => { - server.use( - http.post(`${baseUrl}/api/v1/tags`, async ({ request }) => { - // CreateTagCommand marks description required-but-nullable in the - // OpenAPI schema, not optional - an absent app-side description - // must still be sent as an explicit `null` key, not omitted. - expect(await request.json()).toEqual({ - name: 'VIP', - color: '#0d9488', - description: null, - }) - return HttpResponse.json(tagFixture, { status: 201 }) - }), - ) - const repository = buildRepository() - - const result = await repository.create({ name: 'VIP', color: '#0d9488' }) - - expect(result.success).toBe(true) - if (!result.success) return - expect(result.value.id).toBe(tagFixture.id) - }) - - it('creates a tag, sending a provided description as-is', async () => { - server.use( - http.post(`${baseUrl}/api/v1/tags`, async ({ request }) => { - expect(await request.json()).toEqual({ - name: 'VIP', - color: '#0d9488', - description: 'High-value returning client', - }) - return HttpResponse.json(tagFixture, { status: 201 }) - }), - ) - const repository = buildRepository() - - await repository.create({ - name: 'VIP', - color: '#0d9488', - description: 'High-value returning client', - }) - }) - - it('updates a tag at the correct path', async () => { - server.use( - http.put(`${baseUrl}/api/v1/tags/tag-1`, async ({ request }) => { - // tagId mirrors the route id explicitly (docs/adr/010) - the - // backend overwrites it regardless, but the two must never - // structurally be able to diverge. - expect(await request.json()).toEqual({ - tagId: 'tag-1', - name: 'Renamed', - color: '#ef4444', - description: null, - }) - return HttpResponse.json({ ...tagFixture, name: 'Renamed', color: '#ef4444' }) - }), - ) - const repository = buildRepository() - - const result = await repository.update('tag-1', { - name: 'Renamed', - color: '#ef4444', - }) - - expect(result.success).toBe(true) - if (!result.success) return - expect(result.value.name).toBe('Renamed') - }) - - it('deletes a tag at the correct path', async () => { - let deleteWasCalled = false - server.use( - http.delete(`${baseUrl}/api/v1/tags/tag-1`, () => { - deleteWasCalled = true - return new HttpResponse(null, { status: 204 }) - }), - ) - const repository = buildRepository() - - await repository.delete('tag-1') - - expect(deleteWasCalled).toBe(true) - }) - - it('propagates a curated AppError from the HttpClient on a non-2xx response, not the raw backend title', async () => { - server.use( - http.get(`${baseUrl}/api/v1/tags`, () => - HttpResponse.json({ title: 'Something went wrong' }, { status: 500 }), - ), - ) - const repository = buildRepository() - - const result = await repository.listAll() - - expect(result.success).toBe(false) - if (result.success) return - expect(result.error.message).toBe('Não foi possível concluir a operação. Tente novamente.') - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiTagRepository.ts b/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiTagRepository.ts deleted file mode 100644 index a67538c..0000000 --- a/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiTagRepository.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { Tag } from '@/features/catalog/domain/entities/Tag' -import type { - CreateTagInput, - ListAllTagsOptions, - TagRepository, - UpdateTagInput, -} from '@/features/catalog/application/repositories/TagRepository' -import type { HttpClient } from '@/shared/application/HttpClient' -import type { AppError } from '@/shared/application/AppError' -import { flatMapResult, combineResults, type Result } from '@/shared/application/Result' -import { - mapTagDtoToDomain, - decodeTagDto, - decodeTagDtoArray, -} from '@/features/catalog/infrastructure/mappers/tagMapper' -import type { components } from '@/features/catalog/infrastructure/generated/services-api' - -// The route id is always keyed into the PUT body too (docs/adr/010, docs/adr/0007) -// so the two are structurally incapable of diverging. -type CreateTagRequestBody = components['schemas']['CreateTagCommand'] -type UpdateTagRequestBody = components['schemas']['UpdateTagCommand'] - -const TAGS_URL = '/api/v1/tags' - -// Tenant scope travels in the X-Tenant-Id header the HttpClient attaches - -// no tenantContext parameter here, matching TagRepository's contract. -export class ApiTagRepository implements TagRepository { - private readonly httpClient: HttpClient - - constructor(httpClient: HttpClient) { - this.httpClient = httpClient - } - - async listAll(options: ListAllTagsOptions = {}): Promise> { - const query = new URLSearchParams() - if (options.search !== undefined && options.search.trim() !== '') { - query.set('search', options.search.trim()) - } - const suffix = query.toString() === '' ? '' : `?${query.toString()}` - const result = await this.httpClient.get(`${TAGS_URL}${suffix}`, decodeTagDtoArray) - return flatMapResult(result, dtos => combineResults(dtos.map(mapTagDtoToDomain))) - } - - async create(input: CreateTagInput): Promise> { - const body = { - name: input.name, - color: input.color, - description: input.description ?? null, - } satisfies CreateTagRequestBody - const result = await this.httpClient.post(TAGS_URL, body, decodeTagDto) - return flatMapResult(result, mapTagDtoToDomain) - } - - async update(id: string, input: UpdateTagInput): Promise> { - const body: UpdateTagRequestBody = { - tagId: id, - name: input.name, - color: input.color, - description: input.description ?? null, - } - const result = await this.httpClient.put(`${TAGS_URL}/${id}`, body, decodeTagDto) - return flatMapResult(result, mapTagDtoToDomain) - } - - async delete(id: string): Promise> { - return this.httpClient.delete(`${TAGS_URL}/${id}`) - } -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/TagsPage.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/tags/TagsPage.test.tsx deleted file mode 100644 index 0cf5819..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/TagsPage.test.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { render, screen, within, fireEvent, act } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router' -import { TagsPage } from '@/features/catalog/presentation/tags/TagsPage' -import { TagEditorDialog } from '@/features/catalog/presentation/tags/pages/TagEditorDialog' -import { AppContainerContext } from '@/app/providers/AppContainerContext' -import { AuthProvider } from '@/features/auth' -import type { AppContainer, CatalogFacade } from '@/app/composition/container' -import { Tag } from '@/features/catalog/domain/entities/Tag' -import { Tenant, User } from '@/test/fixtures/authEntityFixtures' -import { MALICIOUS_PAYLOADS } from '@/test/fixtures/maliciousPayloads' -import { createFakeAppContainer } from '@/test/fixtures/createFakeAppContainer' -import { AppError } from '@/shared/application/AppError' -import { success, failure } from '@/shared/application/Result' -import { unwrapResult } from '@/test/fixtures/unwrapResult' - -const tenant = Tenant.create('tenant-123') -const tenantContext = { tenant, user: User.create({ id: 'user-1', tenant }) } -const vipTag = unwrapResult( - Tag.create({ - id: 'tag-1', - name: 'VIP', - color: '#0d9488', - description: 'High-value client', - }), -) - -function buildContainer(overrides: Partial = {}): AppContainer { - return createFakeAppContainer({ - auth: { getCurrentSession: { execute: vi.fn(() => Promise.resolve(tenantContext)) } }, - catalog: { - listTags: { execute: vi.fn(() => Promise.resolve(success([vipTag]))) }, - createTag: { execute: vi.fn(() => Promise.resolve(success(vipTag))) }, - updateTag: { execute: vi.fn(() => Promise.resolve(success(vipTag))) }, - deleteTag: { execute: vi.fn(() => Promise.resolve(success(undefined))) }, - ...overrides, - }, - }) -} - -function renderTagsPage(container: AppContainer): void { - const routes: RouteObject[] = [ - { - path: '/tags', - element: , - children: [ - { path: 'new', element: }, - { path: ':id/edit', element: }, - ], - }, - ] - const router = createMemoryRouter(routes, { initialEntries: ['/tags'] }) - - render( - - - - - , - ) -} - -describe('TagsPage', () => { - it('renders the tag list once loaded', async () => { - renderTagsPage(buildContainer()) - - expect(await screen.findByText('VIP')).toBeInTheDocument() - expect(screen.getByText('High-value client')).toBeInTheDocument() - }) - - it('shows an empty state when there are no tags', async () => { - renderTagsPage( - buildContainer({ listTags: { execute: vi.fn(() => Promise.resolve(success([]))) } }), - ) - - expect(await screen.findByText(/nenhuma etiqueta ainda/i)).toBeInTheDocument() - }) - - it('shows an error state when loading tags fails', async () => { - renderTagsPage( - buildContainer({ - listTags: { - execute: vi.fn(() => - Promise.resolve( - failure(new AppError({ code: 'network', message: 'network down', retryable: true })), - ), - ), - }, - }), - ) - - expect( - await screen.findByText(/não foi possível carregar as etiquetas: network down/i), - ).toBeInTheDocument() - }) - - it('shows the generic curated message, never a raw error message, when an unexpected error occurs', async () => { - renderTagsPage( - buildContainer({ - listTags: { - // A repository always resolves Result, but toUiError's - // fallback branch (for anything that isn't an AppError instance) is - // still defense-in-depth worth covering here - the cast simulates - // that contract being violated internally. - execute: vi.fn(() => - Promise.resolve( - failure(new Error('undefined.trim is not a function') as unknown as AppError), - ), - ), - }, - }), - ) - - expect( - await screen.findByText( - /não foi possível carregar as etiquetas: ocorreu um erro inesperado/i, - ), - ).toBeInTheDocument() - expect(screen.queryByText(/undefined\.trim/i)).not.toBeInTheDocument() - }) - - it('creates a tag through the form and refreshes the list', async () => { - const createTagSpy = vi.fn(() => Promise.resolve(success(vipTag))) - const listTagsSpy = vi.fn(() => Promise.resolve(success([vipTag]))) - renderTagsPage( - buildContainer({ createTag: { execute: createTagSpy }, listTags: { execute: listTagsSpy } }), - ) - await screen.findByText('VIP') - listTagsSpy.mockClear() - - await userEvent.click(screen.getByRole('button', { name: /nova etiqueta/i })) - await userEvent.type(screen.getByLabelText('Nome'), 'Returning') - await userEvent.click(screen.getByRole('radio', { name: 'Cor #ef4444' })) - await userEvent.click(screen.getByRole('button', { name: /criar etiqueta/i })) - - expect(createTagSpy).toHaveBeenCalledExactlyOnceWith({ - name: 'Returning', - color: '#ef4444', - }) - await vi.waitFor(() => { - expect(listTagsSpy).toHaveBeenCalledTimes(1) - }) - expect(screen.queryByRole('button', { name: /criar etiqueta/i })).not.toBeInTheDocument() - }) - - it('shows a validation error and does not submit when the name is blank', async () => { - const createTagSpy = vi.fn(() => Promise.resolve(success(vipTag))) - renderTagsPage(buildContainer({ createTag: { execute: createTagSpy } })) - await screen.findByText('VIP') - - await userEvent.click(screen.getByRole('button', { name: /nova etiqueta/i })) - await userEvent.click(screen.getByRole('button', { name: /criar etiqueta/i })) - - expect( - await screen.findByText(/o nome da etiqueta deve ter entre 1 e 40 caracteres/i), - ).toBeInTheDocument() - expect(createTagSpy).not.toHaveBeenCalled() - - await userEvent.type(screen.getByLabelText('Nome'), 'Returning') - expect( - screen.queryByText(/o nome da etiqueta deve ter entre 1 e 40 caracteres/i), - ).not.toBeInTheDocument() - }) - - it('does not carry a previously edited tag into a freshly opened create dialog', async () => { - renderTagsPage(buildContainer()) - await screen.findByText('VIP') - - await userEvent.click(screen.getByRole('button', { name: /editar/i })) - const editDialog = await screen.findByRole('dialog') - expect(within(editDialog).getByText('Editar etiqueta')).toBeInTheDocument() - expect(screen.getByLabelText('Nome')).toHaveValue('VIP') - await userEvent.click(screen.getByRole('button', { name: /cancelar/i })) - - await userEvent.click(screen.getByRole('button', { name: /nova etiqueta/i })) - const createDialog = await screen.findByRole('dialog') - expect(within(createDialog).getByText('Nova etiqueta')).toBeInTheDocument() - expect(screen.getByLabelText('Nome')).toHaveValue('') - }) - - it('shows a form error when creation fails and keeps the form open', async () => { - renderTagsPage( - buildContainer({ - createTag: { - execute: vi.fn(() => - Promise.resolve( - failure(new Error('Tag name is already in use.') as unknown as AppError), - ), - ), - }, - }), - ) - await screen.findByText('VIP') - - await userEvent.click(screen.getByRole('button', { name: /nova etiqueta/i })) - await userEvent.type(screen.getByLabelText('Nome'), 'VIP') - await userEvent.click(screen.getByRole('button', { name: /criar etiqueta/i })) - - expect(await screen.findByText('Tag name is already in use.')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /criar etiqueta/i })).toBeInTheDocument() - }) - - describe('structured server errors', () => { - it('maps validation field errors from the API onto the Nome and Descrição fields', async () => { - const validationError = new AppError({ - code: 'validation', - message: 'Ocorreram erros de validação.', - retryable: false, - rawFieldErrors: { - Name: 'O nome é obrigatório.', - Description: 'A descrição é muito longa.', - }, - }) - renderTagsPage( - buildContainer({ - createTag: { execute: vi.fn(() => Promise.resolve(failure(validationError))) }, - }), - ) - await screen.findByText('VIP') - - await userEvent.click(screen.getByRole('button', { name: /nova etiqueta/i })) - await userEvent.type(screen.getByLabelText('Nome'), 'Qualquer') - await userEvent.click(screen.getByRole('button', { name: /criar etiqueta/i })) - - const nameError = await screen.findByText('O nome é obrigatório.') - expect(nameError).toHaveAttribute('role', 'alert') - const descriptionError = screen.getByText('A descrição é muito longa.') - expect(descriptionError).toHaveAttribute('role', 'alert') - // Name is listed first in the backend's `errors` map, so it - not - // Description - receives focus as the "first" mapped field. - expect(screen.getByLabelText('Nome')).toHaveFocus() - }) - - it('maps a duplicate-name conflict code from the API onto the Nome field', async () => { - const conflictError = new AppError({ - code: 'conflict', - message: 'Já existe uma etiqueta com esse nome.', - retryable: false, - backendCode: 'Tag.DuplicateName', - }) - renderTagsPage( - buildContainer({ - createTag: { execute: vi.fn(() => Promise.resolve(failure(conflictError))) }, - }), - ) - await screen.findByText('VIP') - - await userEvent.click(screen.getByRole('button', { name: /nova etiqueta/i })) - await userEvent.type(screen.getByLabelText('Nome'), 'VIP') - await userEvent.click(screen.getByRole('button', { name: /criar etiqueta/i })) - - const fieldError = await screen.findByText('Já existe uma etiqueta com esse nome.') - expect(fieldError).toHaveAttribute('role', 'alert') - expect(screen.getByLabelText('Nome')).toHaveFocus() - }) - }) - - it('edits a tag through the inline form', async () => { - const updateTagSpy = vi.fn(() => Promise.resolve(success(vipTag))) - renderTagsPage(buildContainer({ updateTag: { execute: updateTagSpy } })) - await screen.findByText('VIP') - - await userEvent.click(screen.getByRole('button', { name: /editar/i })) - const nameInput = screen.getByLabelText('Nome') - await userEvent.clear(nameInput) - await userEvent.type(nameInput, 'Renamed') - await userEvent.click(screen.getByRole('button', { name: /salvar alterações/i })) - - expect(updateTagSpy).toHaveBeenCalledExactlyOnceWith('tag-1', { - name: 'Renamed', - color: '#0d9488', - description: 'High-value client', - }) - }) - - describe('delete', () => { - it('shows a confirmation dialog naming the tag before deleting', async () => { - renderTagsPage(buildContainer()) - await screen.findByText('VIP') - - await userEvent.click(screen.getByRole('button', { name: /excluir/i })) - - const alertDialog = await screen.findByRole('alertdialog') - expect(within(alertDialog).getByText(/excluir etiqueta/i)).toBeInTheDocument() - expect(within(alertDialog).getByText(/"VIP"/)).toBeInTheDocument() - }) - - it('deletes the tag when the confirmation is accepted', async () => { - const deleteTagSpy = vi.fn(() => Promise.resolve(success(undefined))) - renderTagsPage(buildContainer({ deleteTag: { execute: deleteTagSpy } })) - await screen.findByText('VIP') - - await userEvent.click(screen.getByRole('button', { name: /excluir/i })) - const alertDialog = await screen.findByRole('alertdialog') - await userEvent.click(within(alertDialog).getByRole('button', { name: 'Excluir' })) - - expect(deleteTagSpy).toHaveBeenCalledExactlyOnceWith('tag-1') - await vi.waitFor(() => { - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() - }) - }) - - it('does not delete the tag when the confirmation is cancelled', async () => { - const deleteTagSpy = vi.fn(() => Promise.resolve(success(undefined))) - renderTagsPage(buildContainer({ deleteTag: { execute: deleteTagSpy } })) - await screen.findByText('VIP') - - await userEvent.click(screen.getByRole('button', { name: /excluir/i })) - const alertDialog = await screen.findByRole('alertdialog') - await userEvent.click(within(alertDialog).getByRole('button', { name: /cancelar/i })) - - expect(deleteTagSpy).not.toHaveBeenCalled() - await vi.waitFor(() => { - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() - }) - }) - - it('shows an error and keeps the dialog open when deletion fails', async () => { - const deleteTagSpy = vi.fn(() => - Promise.resolve( - failure(new AppError({ code: 'conflict', message: 'Tag is in use.', retryable: false })), - ), - ) - renderTagsPage(buildContainer({ deleteTag: { execute: deleteTagSpy } })) - await screen.findByText('VIP') - - await userEvent.click(screen.getByRole('button', { name: /excluir/i })) - const alertDialog = await screen.findByRole('alertdialog') - await userEvent.click(within(alertDialog).getByRole('button', { name: 'Excluir' })) - - expect(await within(alertDialog).findByText('Tag is in use.')).toBeInTheDocument() - expect(screen.getByRole('alertdialog')).toBeInTheDocument() - }) - }) - - describe('search', () => { - it('refetches with the debounced search term after the user stops typing', async () => { - const listTagsSpy = vi.fn(() => Promise.resolve(success([vipTag]))) - renderTagsPage(buildContainer({ listTags: { execute: listTagsSpy } })) - await screen.findByText('VIP') - listTagsSpy.mockClear() - - vi.useFakeTimers() - try { - fireEvent.change(screen.getByLabelText('Buscar etiqueta por nome'), { - target: { value: 'vip' }, - }) - expect(listTagsSpy).not.toHaveBeenCalled() - - await act(async () => { - await vi.advanceTimersByTimeAsync(300) - }) - - expect(listTagsSpy).toHaveBeenCalledExactlyOnceWith({ search: 'vip' }) - } finally { - vi.useRealTimers() - } - }) - }) - - describe('security', () => { - it.each(MALICIOUS_PAYLOADS)('renders "%s" as inert text, not markup', async payload => { - const maliciousTag = unwrapResult( - Tag.create({ id: 'malicious-1', name: payload, color: '#0d9488' }), - ) - renderTagsPage( - buildContainer({ - listTags: { execute: vi.fn(() => Promise.resolve(success([maliciousTag]))) }, - }), - ) - - expect(await screen.findByText(payload)).toBeInTheDocument() - expect(document.querySelector('script')).not.toBeInTheDocument() - expect(document.querySelector('img[onerror]')).not.toBeInTheDocument() - }) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/TagsPage.tsx b/apps/admin-frontend/src/features/catalog/presentation/tags/TagsPage.tsx deleted file mode 100644 index 3690281..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/TagsPage.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { JSX } from 'react' -import { Outlet, useNavigate } from 'react-router' -import { PageHeader } from '@/shared/presentation/components/PageHeader' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { DeleteConfirmationDialog } from '@/shared/presentation/components/DeleteConfirmationDialog' -import { useTagsPage } from '@/features/catalog/presentation/tags/hooks/useTagsPage' -import { TagsTable } from '@/features/catalog/presentation/tags/components/TagsTable' - -export function TagsPage(): JSX.Element { - const navigate = useNavigate() - const { - searchInput, - onSearchInputChange, - tags, - listState, - hasActiveSearch, - onRetry, - editorContext, - onDelete, - deleteDialog, - } = useTagsPage() - - function handleEdit(tag: (typeof tags)[number]): void { - void navigate(`/tags/${tag.id}/edit`) - } - - return ( - <> -
- { - void navigate('/tags/new') - }} - > - Nova etiqueta - - } - /> - -
- { - onSearchInputChange(event.target.value) - }} - /> -
- - -
- - - - - - ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/components/TagsTable.tsx b/apps/admin-frontend/src/features/catalog/presentation/tags/components/TagsTable.tsx deleted file mode 100644 index 0adb0ec..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/components/TagsTable.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import type { JSX } from 'react' -import type { Tag } from '@/features/catalog/domain/entities/Tag' -import { Button } from '@/components/ui/button' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { CollectionFeedback } from '@/shared/presentation/components/CollectionFeedback' -import type { AsyncState } from '@/shared/presentation/hooks/useAsync' -import type { UiError } from '@/shared/application/UiError' - -export interface TagsTableProps { - tags: readonly Tag[] - listState: AsyncState - hasActiveSearch: boolean - onRetry: () => void - onEdit: (tag: Tag) => void - onDelete: (tag: Tag) => void -} - -export function TagsTable({ - tags, - listState, - hasActiveSearch, - onRetry, - onEdit, - onDelete, -}: TagsTableProps): JSX.Element { - return ( -
- - - {tags.length > 0 && ( -
- - - - Etiqueta - Descrição - Ações - - - - {tags.map(tag => ( - - -
-
-
- - {tag.description ?? '—'} - - -
- - -
-
-
- ))} -
-
-
- )} -
- ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/forms/TagForm.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/tags/forms/TagForm.test.tsx deleted file mode 100644 index ed1a8eb..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/forms/TagForm.test.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { axe } from 'jest-axe' -import { - TagForm, - type TagFormValues, - type TagFormField, -} from '@/features/catalog/presentation/tags/forms/TagForm' -import { TAG_COLOR_PALETTE } from '@/features/catalog/domain/entities/Tag' -import type { ServerFormError } from '@/shared/presentation/forms/serverFormError' - -const EMPTY_VALUES: TagFormValues = { name: '', color: TAG_COLOR_PALETTE[0], description: '' } - -interface RenderOverrides { - initialValues?: TagFormValues - serverError?: ServerFormError | null - isSubmitting?: boolean -} - -function renderForm(overrides: RenderOverrides = {}): { - onSubmit: ReturnType - onCancel: ReturnType - container: HTMLElement -} { - const onSubmit = vi.fn(() => Promise.resolve()) - const onCancel = vi.fn() - const { container } = render( - , - ) - return { onSubmit, onCancel, container } -} - -describe('TagForm color picker accessibility', () => { - it('groups the color options under a fieldset/legend named "Cor"', () => { - renderForm() - - expect(screen.getByRole('group', { name: 'Cor' })).toBeInTheDocument() - }) - - it('renders a labeled radio for every palette color', () => { - renderForm() - - for (const color of TAG_COLOR_PALETTE) { - expect(screen.getByRole('radio', { name: `Cor ${color}` })).toBeInTheDocument() - } - }) - - it('marks the initial value as checked', () => { - renderForm() - - expect(screen.getByRole('radio', { name: `Cor ${TAG_COLOR_PALETTE[0]}` })).toBeChecked() - expect(screen.getByRole('radio', { name: `Cor ${TAG_COLOR_PALETTE[1]}` })).not.toBeChecked() - }) - - it('lets the user pick a different color via the keyboard (focus + activate)', async () => { - renderForm() - - const target = screen.getByRole('radio', { name: `Cor ${TAG_COLOR_PALETTE[2]}` }) - target.focus() - await userEvent.keyboard(' ') - - expect(target).toBeChecked() - expect(screen.getByRole('radio', { name: `Cor ${TAG_COLOR_PALETTE[0]}` })).not.toBeChecked() - }) - - it('is reachable by Tab in document order alongside the other fields', async () => { - renderForm() - - await userEvent.tab() // Nome - await userEvent.tab() // first color radio - expect(screen.getByRole('radio', { name: `Cor ${TAG_COLOR_PALETTE[0]}` })).toHaveFocus() - }) - - it('renders an accessible, announced error when the color field is invalid', async () => { - const serverError: ServerFormError = { - fieldErrors: [{ field: 'color', message: 'Cor inválida.' }], - firstField: 'color', - globalMessage: null, - } - renderForm({ serverError }) - - const errorMessage = await screen.findByText('Cor inválida.') - expect(errorMessage).toHaveAttribute('role', 'alert') - - const radios = screen.getAllByRole('radio') - for (const radio of radios) { - expect(radio).toHaveAttribute('aria-invalid', 'true') - expect(radio).toHaveAttribute('aria-describedby', errorMessage.id) - } - }) - - it('focuses a color radio when a server error targets the color field', async () => { - const serverError: ServerFormError = { - fieldErrors: [{ field: 'color', message: 'Cor inválida.' }], - firstField: 'color', - globalMessage: null, - } - renderForm({ serverError }) - - await screen.findByText('Cor inválida.') - - const radios = screen.getAllByRole('radio') - expect(radios.some(radio => radio === document.activeElement)).toBe(true) - }) - - it('does not render an error for color when the form has no errors', () => { - renderForm() - - for (const radio of screen.getAllByRole('radio')) { - expect(radio).not.toHaveAttribute('aria-invalid', 'true') - } - }) - - it('has no axe violations in its default state', async () => { - const { container } = renderForm() - - expect(await axe(container)).toHaveNoViolations() - }) - - it('has no axe violations while showing the color validation error', async () => { - const serverError: ServerFormError = { - fieldErrors: [{ field: 'color', message: 'Cor inválida.' }], - firstField: 'color', - globalMessage: null, - } - const { container } = renderForm({ serverError }) - - await screen.findByText('Cor inválida.') - - expect(await axe(container)).toHaveNoViolations() - }) -}) - -describe('TagForm general behavior', () => { - it('disables the submit button while the name field is invalid', async () => { - renderForm() - - await userEvent.click(screen.getByLabelText('Nome')) - await userEvent.tab() - - expect(await screen.findByText(/deve ter entre 1 e 40 caracteres/)).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Criar etiqueta' })).toBeDisabled() - }) - - it('submits the current values, including the selected color, when valid', async () => { - const { onSubmit } = renderForm() - - await userEvent.type(screen.getByLabelText('Nome'), 'VIP') - const target = screen.getByRole('radio', { name: `Cor ${TAG_COLOR_PALETTE[3]}` }) - await userEvent.click(target) - await userEvent.click(screen.getByRole('button', { name: 'Criar etiqueta' })) - - expect(onSubmit).toHaveBeenCalledTimes(1) - expect(onSubmit.mock.calls[0]?.[0]).toEqual({ - name: 'VIP', - color: TAG_COLOR_PALETTE[3], - description: '', - }) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/forms/TagForm.tsx b/apps/admin-frontend/src/features/catalog/presentation/tags/forms/TagForm.tsx deleted file mode 100644 index 415dac5..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/forms/TagForm.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useEffect, type JSX } from 'react' -import { useForm, useWatch } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { z } from 'zod' -import { TAG_COLOR_PALETTE } from '@/features/catalog/domain/entities/Tag' -import { TextField } from '@/shared/presentation/components/TextField' -import { TextAreaField } from '@/shared/presentation/components/TextAreaField' -import { Button } from '@/components/ui/button' -import { Spinner } from '@/components/ui/spinner' -import { StatusMessage } from '@/shared/presentation/components/StatusMessage' -import type { ServerFormError } from '@/shared/presentation/forms/serverFormError' - -const NAME_MESSAGE = 'O nome da etiqueta deve ter entre 1 e 40 caracteres' -const COLOR_MESSAGE = `A cor da etiqueta deve ser uma das seguintes: ${TAG_COLOR_PALETTE.join(', ')}` -const DESCRIPTION_MESSAGE = 'A descrição da etiqueta deve ter no máximo 200 caracteres' - -const tagFormSchema = z.object({ - name: z.string().trim().min(1, NAME_MESSAGE).max(40, NAME_MESSAGE), - color: z.enum(TAG_COLOR_PALETTE, { message: COLOR_MESSAGE }), - description: z.string().trim().max(200, DESCRIPTION_MESSAGE), -}) - -export type TagFormValues = z.infer -export type TagFormField = keyof TagFormValues - -interface TagFormProps { - initialValues: TagFormValues - submitLabel: string - isSubmitting: boolean - serverError: ServerFormError | null - onCancel: () => void - onSubmit: (values: TagFormValues) => Promise -} - -export function TagForm({ - initialValues, - submitLabel, - isSubmitting, - serverError, - onCancel, - onSubmit, -}: TagFormProps): JSX.Element { - const { - register, - control, - handleSubmit, - setError, - setFocus, - formState: { errors }, - } = useForm({ - resolver: zodResolver(tagFormSchema), - defaultValues: initialValues, - mode: 'onTouched', - reValidateMode: 'onChange', - }) - const descriptionValue = useWatch({ control, name: 'description' }) - const hasErrors = Object.keys(errors).length > 0 - const colorErrorId = 'tag-color-error' - - useEffect(() => { - if (serverError === null) { - return - } - for (const { field, message } of serverError.fieldErrors) { - setError(field, { type: 'server', message }) - } - if (serverError.firstField !== null) { - setFocus(serverError.firstField) - } - }, [serverError, setError, setFocus]) - - return ( -
void handleSubmit(onSubmit)(e)} noValidate className="space-y-4"> - {/* No native `required` here - the browser's own constraint validation - would intercept the submit event before react-hook-form ever sees - it. zod already enforces required-ness with a proper message. */} - - -
- Cor -
- {TAG_COLOR_PALETTE.map(paletteColor => ( - - ))} -
- {errors.color?.message !== undefined && ( - - )} -
- - - - {serverError?.globalMessage != null && ( - {serverError.globalMessage} - )} - -
- - -
- - ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/forms/tagFieldMaps.ts b/apps/admin-frontend/src/features/catalog/presentation/tags/forms/tagFieldMaps.ts deleted file mode 100644 index 68bd165..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/forms/tagFieldMaps.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { TagFormField } from '@/features/catalog/presentation/tags/forms/TagForm' - -// Kept out of TagForm.tsx itself: a component file exporting a plain -// runtime constant alongside its component breaks Vite Fast Refresh for -// that file (react-refresh/only-export-components). - -/** Backend PascalCase property name -> TagForm's field name. */ -export const tagFieldMap: Record = { - Name: 'name', - Color: 'color', - Description: 'description', -} - -/** Conflict/NotFound/Forbidden `code` -> the TagForm field it should highlight. */ -export const tagCodeFieldMap: Record = { - 'Tag.DuplicateName': 'name', -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTagEditor.ts b/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTagEditor.ts deleted file mode 100644 index 34a5ec4..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTagEditor.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { useState } from 'react' -import { useNavigate, useOutletContext, useParams } from 'react-router' -import type { - TagFormField, - TagFormValues, -} from '@/features/catalog/presentation/tags/forms/TagForm' -import { - tagCodeFieldMap, - tagFieldMap, -} from '@/features/catalog/presentation/tags/forms/tagFieldMaps' -import type { UseTagsResult } from '@/features/catalog/presentation/tags/hooks/useTags' -import { - mapApiErrorToForm, - type ServerFormError, -} from '@/shared/presentation/forms/serverFormError' -import { TAG_COLOR_PALETTE } from '@/features/catalog/domain/entities/Tag' - -const EMPTY_FORM_VALUES: TagFormValues = { - name: '', - color: TAG_COLOR_PALETTE[0], - description: '', -} - -export type TagEditorContent = - | { status: 'loading' } - | { status: 'loadError'; message: string; onRetry: () => void } - | { status: 'notFound' } - | { status: 'ready'; initialValues: TagFormValues } - -export interface UseTagEditorResult { - title: string - submitLabel: string - formKey: string - content: TagEditorContent - isSubmitting: boolean - serverError: ServerFormError | null - onCancel: () => void - onSubmit: (values: TagFormValues) => Promise -} - -function toTagInput(values: TagFormValues): { - name: string - color: string - description?: string -} { - const description = values.description.trim() - return { - name: values.name, - color: values.color, - ...(description !== '' ? { description } : {}), - } -} - -export function useTagEditor(): UseTagEditorResult { - const { id: tagId } = useParams<{ id: string }>() - const navigate = useNavigate() - const { tags, listState, refetch, createTag, updateTag } = useOutletContext() - const [isSubmitting, setIsSubmitting] = useState(false) - const [serverError, setServerError] = useState | null>(null) - const isEditing = tagId !== undefined - - function closeEditor(): void { - void navigate('..', { replace: true }) - } - - async function onSubmit(values: TagFormValues): Promise { - setIsSubmitting(true) - setServerError(null) - const result = - tagId === undefined - ? await createTag(toTagInput(values)) - : await updateTag(tagId, toTagInput(values)) - if (result.success) { - closeEditor() - } else { - setServerError( - mapApiErrorToForm( - result.error, - tagFieldMap, - tagCodeFieldMap, - isEditing ? 'Não foi possível salvar a etiqueta.' : 'Não foi possível criar a etiqueta.', - ), - ) - } - setIsSubmitting(false) - } - - let content: TagEditorContent - if (!isEditing) { - content = { status: 'ready', initialValues: EMPTY_FORM_VALUES } - } else if (listState.status === 'idle' || listState.status === 'loading') { - content = { status: 'loading' } - } else if (listState.status === 'initialError') { - content = { - status: 'loadError', - message: listState.error.message, - onRetry: () => void refetch(), - } - } else { - const tag = tags.find(item => item.id === tagId) - content = - tag === undefined - ? { status: 'notFound' } - : { - status: 'ready', - initialValues: { - name: tag.name, - color: tag.color, - description: tag.description ?? '', - }, - } - } - - return { - title: isEditing ? 'Editar etiqueta' : 'Nova etiqueta', - submitLabel: isEditing ? 'Salvar alterações' : 'Criar etiqueta', - formKey: tagId ?? 'new', - content, - isSubmitting, - serverError, - onCancel: closeEditor, - onSubmit, - } -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTags.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTags.test.tsx deleted file mode 100644 index 8b33d57..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTags.test.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { renderHook, waitFor, act, type RenderHookResult } from '@testing-library/react' -import { useTags, type UseTagsResult } from '@/features/catalog/presentation/tags/hooks/useTags' -import { AppContainerContext } from '@/app/providers/AppContainerContext' -import { createFakeAppContainer } from '@/test/fixtures/createFakeAppContainer' -import type { AppContainer, CatalogFacade } from '@/app/composition/container' -import { Tag } from '@/features/catalog/domain/entities/Tag' -import type { TenantContext } from '@/features/auth' -import { Tenant, User } from '@/test/fixtures/authEntityFixtures' -import { success, failure, type Result } from '@/shared/application/Result' -import { AppError } from '@/shared/application/AppError' -import { unwrapResult } from '@/test/fixtures/unwrapResult' - -const tagFixture = unwrapResult(Tag.create({ id: 'tag-1', name: 'VIP', color: '#0d9488' })) - -function createFakeContainer(overrides: Partial = {}): AppContainer { - return createFakeAppContainer({ - catalog: { - listTags: { execute: vi.fn(() => Promise.resolve(success([tagFixture]))) }, - createTag: { execute: vi.fn(() => Promise.resolve(success(tagFixture))) }, - updateTag: { execute: vi.fn(() => Promise.resolve(success(tagFixture))) }, - deleteTag: { execute: vi.fn(() => Promise.resolve(success(undefined))) }, - ...overrides, - }, - }) -} - -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - -function renderUseTags( - container: AppContainer, - tenantContext: TenantContext | null, -): RenderHookResult { - return renderHook(() => useTags(tenantContext), { - wrapper: ({ children }) => ( - {children} - ), - }) -} - -describe('useTags', () => { - it('loads tags for the given tenant context', async () => { - const { result } = renderUseTags(createFakeContainer(), buildTenantContext()) - - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - - expect(result.current.tags).toEqual([tagFixture]) - }) - - it('returns an empty list without calling the use case when tenantContext is null', async () => { - const listTagsSpy = vi.fn(() => Promise.resolve(success([tagFixture]))) - const { result } = renderUseTags( - createFakeContainer({ listTags: { execute: listTagsSpy } }), - null, - ) - - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - - expect(result.current.tags).toEqual([]) - expect(listTagsSpy).not.toHaveBeenCalled() - }) - - it('creates a tag then refetches the list', async () => { - const listTagsSpy = vi.fn(() => Promise.resolve(success([tagFixture]))) - const createTagSpy = vi.fn(() => Promise.resolve(success(tagFixture))) - const tenantContext = buildTenantContext() - const { result } = renderUseTags( - createFakeContainer({ - listTags: { execute: listTagsSpy }, - createTag: { execute: createTagSpy }, - }), - tenantContext, - ) - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - listTagsSpy.mockClear() - - await act(async () => { - await result.current.createTag({ name: 'VIP', color: '#0d9488' }) - }) - - expect(createTagSpy).toHaveBeenCalledExactlyOnceWith({ - name: 'VIP', - color: '#0d9488', - }) - // The refetch fires in the background (not awaited by createTag - // itself) - wait for it rather than asserting immediately. - await waitFor(() => { - expect(listTagsSpy).toHaveBeenCalledTimes(1) - }) - }) - - it('keeps the created tag visible even if the background refetch fails', async () => { - const newTag = unwrapResult(Tag.create({ id: 'tag-2', name: 'Returning', color: '#ef4444' })) - const listTagsSpy = vi - .fn<() => Promise>>() - .mockResolvedValueOnce(success([tagFixture])) - .mockResolvedValueOnce( - failure(new AppError({ code: 'network', message: 'network down', retryable: true })), - ) - const createTagSpy = vi.fn(() => Promise.resolve(success(newTag))) - const tenantContext = buildTenantContext() - const { result } = renderUseTags( - createFakeContainer({ - listTags: { execute: listTagsSpy }, - createTag: { execute: createTagSpy }, - }), - tenantContext, - ) - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - - await act(async () => { - await expect( - result.current.createTag({ name: 'Returning', color: '#ef4444' }), - ).resolves.toEqual(success(newTag)) - }) - - // The optimistic insert survives the refetch failure below. - expect(result.current.tags).toEqual([tagFixture, newTag]) - - await waitFor(() => { - expect(result.current.listState.status).toBe('refreshError') - }) - // Still there after the failed refetch settles - not cleared, not - // reported as a failed creation, so it keeps showing as a chip. - expect(result.current.tags).toEqual([tagFixture, newTag]) - }) - - it('deletes a tag then refetches the list', async () => { - const listTagsSpy = vi.fn(() => Promise.resolve(success([tagFixture]))) - const deleteTagSpy = vi.fn(() => Promise.resolve(success(undefined))) - const tenantContext = buildTenantContext() - const { result } = renderUseTags( - createFakeContainer({ - listTags: { execute: listTagsSpy }, - deleteTag: { execute: deleteTagSpy }, - }), - tenantContext, - ) - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - listTagsSpy.mockClear() - - await act(async () => { - await result.current.deleteTag('tag-1') - }) - - expect(deleteTagSpy).toHaveBeenCalledExactlyOnceWith('tag-1') - expect(listTagsSpy).toHaveBeenCalledTimes(1) - }) - - it('resolves to a Failure when tenantContext is null', async () => { - const { result } = renderUseTags(createFakeContainer(), null) - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - - const outcome = await result.current.createTag({ name: 'VIP', color: '#0d9488' }) - - expect(outcome.success).toBe(false) - }) - - it('does not let a create started against the previous tenant leak into the new tenant after a switch', async () => { - const tenantA = buildTenantContext() - const tenantBValue = Tenant.create('tenant-456') - const tenantB: TenantContext = { - tenant: tenantBValue, - user: User.create({ id: 'user-1', tenant: tenantBValue }), - } - - let resolveCreate: ((tag: Tag) => void) | undefined - const createTagSpy = vi.fn( - () => - new Promise>(resolve => { - resolveCreate = tag => { - resolve(success(tag)) - } - }), - ) - const listTagsSpy = vi - .fn<() => Promise>>() - .mockResolvedValueOnce(success([tagFixture])) // tenant A's initial load - .mockResolvedValueOnce(success([])) // tenant B's auto-fetch right after the switch - .mockResolvedValue(success([tagFixture])) // any further stale tenant-A refetch - - const container = createFakeContainer({ - listTags: { execute: listTagsSpy }, - createTag: { execute: createTagSpy }, - }) - - const { result, rerender } = renderHook( - ({ tenantContext }) => useTags(tenantContext), - { - wrapper: ({ children }) => ( - {children} - ), - initialProps: { tenantContext: tenantA }, - }, - ) - - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - expect(result.current.tags).toEqual([tagFixture]) - - // Start a create against tenant A - deliberately left pending. - let createPromise: Promise> | undefined - act(() => { - createPromise = result.current.createTag({ name: 'VIP', color: '#0d9488' }) - }) - - // Switch to tenant B before the create resolves. - rerender({ tenantContext: tenantB }) - await waitFor(() => { - expect(result.current.tags).toEqual([]) - }) - - // Tenant A's create finally resolves. The extra microtask flushes give - // its background `void execute()` (fired after mutate, not awaited by - // createTag itself) a chance to settle before the assertion below, so - // this test would actually fail if the tenant-switch guard regressed. - await act(async () => { - resolveCreate?.(tagFixture) - await createPromise - await Promise.resolve() - await Promise.resolve() - }) - - // Tenant B's list must still be empty - the stale create's optimistic - // insert and its own background refetch must not have applied. - expect(result.current.tags).toEqual([]) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTags.ts b/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTags.ts deleted file mode 100644 index 85324f8..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTags.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { useCallback } from 'react' -import { useAppContainer } from '@/app/providers/useAppContainer' -import { useAsync, toUiAsyncState, type AsyncState } from '@/shared/presentation/hooks/useAsync' -import type { UiError } from '@/shared/application/UiError' -import { AppError } from '@/shared/application/AppError' -import { failure, success, type Result } from '@/shared/application/Result' -import type { Tag } from '@/features/catalog/domain/entities/Tag' -import type { TenantContext } from '@/features/auth' -import type { - CreateTagInput, - UpdateTagInput, -} from '@/features/catalog/application/repositories/TagRepository' - -export interface UseTagsResult { - tags: readonly Tag[] - listState: AsyncState - refetch: () => Promise - createTag: (input: CreateTagInput) => Promise> - updateTag: (id: string, input: UpdateTagInput) => Promise> - deleteTag: (id: string) => Promise> -} - -// tenantContext is nullable: the page can mount before useAuth() resolves - -// create/update/delete are never reachable from the UI in that window, but -// this keeps the guard a Result instead of a throw for that same reason -// useCategoryEditor's own unreachable guard is. -const NO_TENANT_CONTEXT_ERROR = new AppError({ - code: 'unexpected', - message: 'Não é possível concluir esta ação sem um contexto de tenant autenticado.', - retryable: false, -}) - -// tenantContext is nullable: the page can mount before useAuth() resolves. -// Guards below no-op until it does, then the changed identity re-triggers the fetch. -export function useTags(tenantContext: TenantContext | null, search = ''): UseTagsResult { - const { catalog } = useAppContainer() - - const listTags = useCallback((): Promise> => { - if (tenantContext === null) { - return Promise.resolve(success([])) - } - return catalog.listTags.execute({ search }) - }, [tenantContext, catalog, search]) - - const asyncState = useAsync(listTags, { resetKey: tenantContext?.tenant.id }) - const { data, execute, mutate, captureGeneration } = asyncState - - const createTag = useCallback( - async (input: CreateTagInput): Promise> => { - if (tenantContext === null) { - return failure(NO_TENANT_CONTEXT_ERROR) - } - // Captured before the POST starts: if the tenant switches while this - // request is in flight, the mutate below must not insert tenant A's - // newly created tag into what is now tenant B's list. - const generation = captureGeneration() - const createResult = await catalog.createTag.execute(input) - if (createResult.success) { - // Insert immediately so the new tag is selectable and shows up as - // soon as the POST succeeds - the mutation's success never depends - // on the background refetch below. If that refetch fails, this - // optimistic entry is what keeps the tag visible as a chip (see - // useAsync's own status/error, surfaced separately by the page). - mutate(current => [...(current ?? []), createResult.value], generation) - void execute() - } - return createResult - }, - [tenantContext, catalog, execute, mutate, captureGeneration], - ) - - const updateTag = useCallback( - async (id: string, input: UpdateTagInput): Promise> => { - if (tenantContext === null) { - return failure(NO_TENANT_CONTEXT_ERROR) - } - const updateResult = await catalog.updateTag.execute(id, input) - if (updateResult.success) { - await execute() - } - return updateResult - }, - [tenantContext, catalog, execute], - ) - - const deleteTag = useCallback( - async (id: string): Promise> => { - if (tenantContext === null) { - return failure(NO_TENANT_CONTEXT_ERROR) - } - const deleteResult = await catalog.deleteTag.execute(id) - if (deleteResult.success) { - await execute() - } - return deleteResult - }, - [tenantContext, catalog, execute], - ) - - return { - tags: data ?? [], - listState: toUiAsyncState(asyncState), - refetch: async () => { - await execute() - }, - createTag, - updateTag, - deleteTag, - } -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTagsPage.ts b/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTagsPage.ts deleted file mode 100644 index 7b7a326..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTagsPage.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { useState } from 'react' -import { useAuth } from '@/features/auth' -import { useTags } from '@/features/catalog/presentation/tags/hooks/useTags' -import type { AsyncState } from '@/shared/presentation/hooks/useAsync' -import type { UiError } from '@/shared/application/UiError' -import { useDebouncedValue } from '@/shared/presentation/hooks/useDebouncedValue' -import { useDeleteConfirmation } from '@/shared/presentation/hooks/useDeleteConfirmation' -import type { Tag } from '@/features/catalog/domain/entities/Tag' -import type { UseTagsResult } from '@/features/catalog/presentation/tags/hooks/useTags' - -export interface UseTagsPageResult { - searchInput: string - onSearchInputChange: (value: string) => void - tags: readonly Tag[] - listState: AsyncState - hasActiveSearch: boolean - onRetry: () => void - editorContext: UseTagsResult - onDelete: (tag: Tag) => void - deleteDialog: { - target: Tag | null - error: string | null - isDeleting: boolean - onCancel: () => void - onConfirm: () => void - } -} - -export function useTagsPage(): UseTagsPageResult { - const { tenantContext } = useAuth() - const [searchInput, setSearchInput] = useState('') - const debouncedSearch = useDebouncedValue(searchInput, 300) - const tagsSource = useTags(tenantContext, debouncedSearch) - const { tags, listState, refetch, deleteTag } = tagsSource - const deletion = useDeleteConfirmation({ - onDelete: tag => deleteTag(tag.id), - }) - - return { - searchInput, - onSearchInputChange: setSearchInput, - tags, - listState, - hasActiveSearch: debouncedSearch.trim() !== '', - onRetry: () => void refetch(), - editorContext: tagsSource, - onDelete: deletion.onRequestDelete, - deleteDialog: { - target: deletion.target, - error: deletion.error, - isDeleting: deletion.isDeleting, - onCancel: deletion.onCancel, - onConfirm: () => void deletion.onConfirm(), - }, - } -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/pages/TagEditorDialog.tsx b/apps/admin-frontend/src/features/catalog/presentation/tags/pages/TagEditorDialog.tsx deleted file mode 100644 index e484027..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/pages/TagEditorDialog.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { JSX } from 'react' -import { Button } from '@/components/ui/button' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { TagForm } from '@/features/catalog/presentation/tags/forms/TagForm' -import { useTagEditor } from '@/features/catalog/presentation/tags/hooks/useTagEditor' -import { StatusMessage } from '@/shared/presentation/components/StatusMessage' - -export function TagEditorDialog(): JSX.Element { - const editor = useTagEditor() - - return ( - { - if (!open) { - editor.onCancel() - } - }} - > - - - {editor.title} - - - {editor.content.status === 'loading' && ( - Carregando etiqueta… - )} - - {editor.content.status === 'loadError' && ( -
- - Não foi possível carregar a etiqueta: {editor.content.message} - - -
- )} - - {editor.content.status === 'notFound' && ( -
- Etiqueta não encontrada. - -
- )} - - {editor.content.status === 'ready' && ( - - )} -
-
- ) -} diff --git a/apps/admin-frontend/src/shared/presentation/components/DeleteConfirmationDialog.tsx b/apps/admin-frontend/src/shared/presentation/components/DeleteConfirmationDialog.tsx index 962b947..1278f13 100644 --- a/apps/admin-frontend/src/shared/presentation/components/DeleteConfirmationDialog.tsx +++ b/apps/admin-frontend/src/shared/presentation/components/DeleteConfirmationDialog.tsx @@ -23,7 +23,7 @@ export interface DeleteConfirmationDialogProps { onConfirm: () => void } -/** Shared delete AlertDialog behind Tags/Categories/Services - generates default title/description from entity info. */ +/** Shared delete AlertDialog behind Categories/Services - generates default title/description from entity info. */ export function DeleteConfirmationDialog({ isOpen, entityName, diff --git a/apps/admin-frontend/src/shared/presentation/hooks/useDeleteConfirmation.ts b/apps/admin-frontend/src/shared/presentation/hooks/useDeleteConfirmation.ts index 3f21d9c..f5afbd5 100644 --- a/apps/admin-frontend/src/shared/presentation/hooks/useDeleteConfirmation.ts +++ b/apps/admin-frontend/src/shared/presentation/hooks/useDeleteConfirmation.ts @@ -16,7 +16,7 @@ export interface UseDeleteConfirmationResult { onConfirm: () => Promise } -/** Shared target/progress/error state behind every delete-with-confirm flow (Tags/Categories/Services). */ +/** Shared target/progress/error state behind every delete-with-confirm flow (Categories/Services). */ export function useDeleteConfirmation({ onDelete, }: UseDeleteConfirmationParams): UseDeleteConfirmationResult { diff --git a/apps/admin-frontend/src/test/fixtures/createFakeAppContainer.ts b/apps/admin-frontend/src/test/fixtures/createFakeAppContainer.ts index 775094e..32dd2db 100644 --- a/apps/admin-frontend/src/test/fixtures/createFakeAppContainer.ts +++ b/apps/admin-frontend/src/test/fixtures/createFakeAppContainer.ts @@ -38,10 +38,6 @@ function defaultAuthFacade(): AuthFacade { function defaultCatalogFacade(): CatalogFacade { return { - listTags: { execute: vi.fn(() => Promise.resolve(success([]))) }, - createTag: { execute: vi.fn(CATALOG_NOT_USED_IN_THIS_FAKE) }, - updateTag: { execute: vi.fn(CATALOG_NOT_USED_IN_THIS_FAKE) }, - deleteTag: { execute: vi.fn(CATALOG_NOT_USED_IN_THIS_FAKE) }, listCategories: { execute: vi.fn(() => Promise.resolve(success([]))) }, getCategory: { execute: vi.fn(CATALOG_NOT_USED_IN_THIS_FAKE) }, createCategory: { execute: vi.fn(CATALOG_NOT_USED_IN_THIS_FAKE) }, diff --git a/apps/admin-frontend/src/test/mocks/handlers/index.ts b/apps/admin-frontend/src/test/mocks/handlers/index.ts index 47cd29d..4794769 100644 --- a/apps/admin-frontend/src/test/mocks/handlers/index.ts +++ b/apps/admin-frontend/src/test/mocks/handlers/index.ts @@ -1,7 +1,6 @@ import type { RequestHandler } from 'msw' -import { tagHandlers } from '@/test/mocks/handlers/tagHandlers' import { categoryHandlers } from '@/test/mocks/handlers/categoryHandlers' // Handlers are added incrementally, one resource at a time, as each // infrastructure-layer repository is built. -export const handlers: RequestHandler[] = [...tagHandlers, ...categoryHandlers] +export const handlers: RequestHandler[] = [...categoryHandlers] diff --git a/apps/admin-frontend/src/test/mocks/handlers/tagHandlers.ts b/apps/admin-frontend/src/test/mocks/handlers/tagHandlers.ts deleted file mode 100644 index f17e751..0000000 --- a/apps/admin-frontend/src/test/mocks/handlers/tagHandlers.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { http, HttpResponse } from 'msw' -import type { TagDto } from '@/features/catalog/infrastructure/mappers/tagMapper' - -const API_BASE_URL = 'https://api.test' - -export const tagFixture: TagDto = { - id: 'tag-1', - name: 'VIP', - color: '#0d9488', - description: 'High-value client', -} - -/** Default happy-path handlers for /api/v1/tags - override per-test with server.use(). */ -export const tagHandlers = [ - http.get(`${API_BASE_URL}/api/v1/tags`, () => HttpResponse.json([tagFixture])), - - http.post(`${API_BASE_URL}/api/v1/tags`, () => - HttpResponse.json(tagFixture, { - status: 201, - headers: { Location: `/api/v1/tags/${tagFixture.id}` }, - }), - ), - - http.put(`${API_BASE_URL}/api/v1/tags/:id`, () => HttpResponse.json(tagFixture)), - - http.delete(`${API_BASE_URL}/api/v1/tags/:id`, () => new HttpResponse(null, { status: 204 })), -]