diff --git a/.claude/skills/agenza-api-contract/SKILL.md b/.claude/skills/agenza-api-contract/SKILL.md index aa0edf58..ca9cba5d 100644 --- a/.claude/skills/agenza-api-contract/SKILL.md +++ b/.claude/skills/agenza-api-contract/SKILL.md @@ -54,6 +54,25 @@ document. It also fires on a backend-only PR, by design — that is the drift it resolved config, so regenerate rather than reformat by hand. 4. Never relax the check, never hand-patch the `.d.ts`. +## Calendar dates cross as `Date`, instants as `string` + +Two rules, one per direction, that only hold together: + +- **Types.** `generateApiTypes.mjs` turns `format: date` into `Date` only in schemas reached solely + from request bodies: it follows the `$ref`s from each operation's `requestBody`, so the DTO's name + (`*Command`, `*Input`, `*Request`) does not matter. A schema that a response or a parameter also + uses stays `string`. A date in a response is therefore always a `string`, and the repository that + reads it converts it (`new Date(isoDate)` is midnight UTC). `format: date-time` is never mapped. + `scripts/generateApiTypes.test.mjs` pins each case against a fixture document (`typesFromDocument`, + no backend needed); change the rule there first. +- **Wire.** `serializeBody` (`apiClient.ts`, the client's `bodySerializer`) writes **every** `Date` + in a request body as `yyyy-MM-dd`, its UTC calendar day; the backend's `DateOnly` binding answers + 400 to an instant. A `Date` in a body always means a calendar day: never carry an instant in one, + send it as the ISO `string` its `date-time` type already asks for. + +Build calendar days with `utcDate`/`todayUtc` (`shared/format/date.ts`), never `new Date(y, m, d)`: +that is local midnight, which east of UTC serializes as the previous day. + ## Write a repository None exists today ([ADR 0038](../../../docs/adr/0038-admin-frontend-remove-categories-harness.md) diff --git a/.claude/skills/agenza-api-contract/references/errors.md b/.claude/skills/agenza-api-contract/references/errors.md index 62fd50a9..465fe7b4 100644 --- a/.claude/skills/agenza-api-contract/references/errors.md +++ b/.claude/skills/agenza-api-contract/references/errors.md @@ -52,14 +52,15 @@ not read as "sem conexão", because the user's actual next step is entirely diff ## `errors` is not always field errors -This is the trap. `errors` is `Record` and gets filled **three +This is the trap. `errors` is `Record` and gets filled **three different ways** (`ApiProblemDetailsFactory`): 1. **Validation (400).** Keys come from FluentValidation's `PropertyName`, grouped in `Dispatcher.cs`, and `ApiProblemDetailsFactory` writes them as the **camelCase path of the JSON body** (`name`, `durationMinutes`, `guardians[0].name`; ADR 0051). A cross-field rule can be re-keyed onto one field with `OverridePropertyName` (see `CreateServiceCommandValidator.cs`), so the key set is not - mechanically the command's properties either. + mechanically the command's properties either. `toFormErrors` turns the index of a list item into a + path segment: `guardians[1].name` becomes `guardians.1.name`. 2. **A body that did not bind (400).** Before any validator runs, MVC fills the keys from the JSON member path (`cpf`, `guardians[0].cpf`), each item with `code: "Validation.Failed"`. The `message` is pt-BR only when a shared value object rejected the value; otherwise it is the framework's English @@ -69,7 +70,13 @@ different ways** (`ApiProblemDetailsFactory`): 3. **Any error without field errors** (Conflict, NotFound, Failure, and a Validation the domain rejected after the validator let it through). `CreateSingleErrorDictionary` puts the error itself under the **empty-string key `""`**. So a 409 arrives with a populated - `errors` that maps to no field at all. + `errors` that maps to no field at all. The exception: a handler may build the `Error` with its own + `FieldErrors`, and then the 409 is keyed by field (`cpf`, `email` in `CreateClient`). + +`meta` is an optional string map on one entry, absent when empty. It carries context for the client, +never prose: the CPF and e-mail conflicts put `clientId` and `clientName` there, and the record can always +be opened because neither conflict matches a deleted person. Branch on the entry's `code` before +trusting a `meta` key. Authorization and unexpected problems carry an empty `errors` object, never `null` in practice — but the type says nullable, so guard anyway. @@ -93,6 +100,8 @@ speculative. Revisit if a real case needs multiple messages per field; don't bui ```ts // shared/api/formErrors.ts (verbatim) +const toFieldPath = (key: string) => key.replace(/\[(\d+)\]/g, '.$1').toLowerCase(); + export function toFormErrors( problem: ApiProblem, fields: readonly F[], @@ -104,7 +113,7 @@ export function toFormErrors( for (const [key, entries] of Object.entries(problem.errors ?? {})) { const message = entries[0]?.message; if (!message) continue; - const field = byField.get(key.toLowerCase()); + const field = byField.get(toFieldPath(key)); if (field) fieldErrors[field] = message; else formLevel.push(message); } diff --git a/.claude/skills/agenza-form-field/SKILL.md b/.claude/skills/agenza-form-field/SKILL.md index 2b8a7547..ee87f360 100644 --- a/.claude/skills/agenza-form-field/SKILL.md +++ b/.claude/skills/agenza-form-field/SKILL.md @@ -5,20 +5,30 @@ description: Use when building or changing a form in admin-frontend — wiring R # Forms — React Hook Form + Zod + `shared/form/fields/` -`TagFormPage` (`src/features/tags/ui/pages/TagFormPage/`) is the reference implementation and, as of -this writing, the **only** consumer of everything described here. Read +`TagFormPage` (`src/features/tags/ui/pages/TagFormPage/`) is the reference implementation for a +dialog form. `ClientFormPage` (`src/features/clients/ui/pages/ClientFormPage/`) is the second +consumer and the reference for a full-page form with **field arrays** (`useFieldArray`, nested paths +such as `guardians.${number}.name`), masked inputs (`MaskedField`), a typed-or-picked date (`DateField`: mask plus calendar popover, bounded by `minDate`/`maxDate`) and a custom multi-value control +(`CheckboxGroup` through a `ControlledField` render function). Read [`docs/ARCHITECTURE.md` §5](../../../apps/admin-frontend/docs/ARCHITECTURE.md) (the "Tag create/edit UI reintroduced on React Hook Form + Zod" row and the row right after it) for the decision history and the two gotchas below in their original context, and §6's "Provisional" section for what's still -unproven with only one consumer. +unproven. + +Backend errors on a list item arrive keyed `guardians[1].name`; `toFormErrors` normalizes that to +`guardians.1.name`, and `applyApiProblem` matches it against the **current** field paths: it derives +them from `methods.getValues()` when the response arrives (top-level plus one per item), so a page +keeps no path list of its own. A field-array root error (e.g. +"informe ao menos um responsável") lives at `errors.guardians.message` / `errors.guardians.root.message` +and has no input to attach to: render it in the section. ## The three layers, one job each | Layer | File | Job | | --- | --- | --- | | Error mapping | `shared/api/formErrors.ts` | `toFormErrors(problem, fields)` — `ApiProblem` → `{fieldErrors, formError}`. Knows `ApiProblem`, nothing about RHF. | -| RHF bridge | `shared/form/applyApiProblem.ts` | `applyApiProblem(problem, fields, setError)` — calls the above, then `setError` per field plus `setError('root.serverError', …)`. Knows both shapes; the only file that does. | -| Field components | `shared/form/fields/` | `TextField`/`TextareaField`, `ControlledField`, `ColorField`, `FormErrorBanner` — RHF wiring, zero domain knowledge. They render into `shared/ui/form-field/`'s `FormField` (label, hint, error, the `aria-*` wiring), which stays presentational and RHF-free like the rest of `shared/ui/` (`agenza-ui-primitive`). | +| RHF bridge | `shared/form/applyApiProblem.ts` | `applyApiProblem(problem, methods)` — builds the current field paths from `methods.getValues()`, calls the above, then `setError` per field, `setError('root.serverError', …)` for the form-level message and `setFocus` on the first invalid field. Knows both shapes; the only file that does. | +| Field components | `shared/form/fields/` | `TextField`/`TextareaField`, `MaskedField`, `DateField`, `ControlledField`, `ColorField`, `FormErrorBanner` — RHF wiring, zero entity knowledge. They render into `shared/ui/form-field/`'s `FormField` (label, hint, error, the `aria-*` wiring), which stays presentational and RHF-free like the rest of `shared/ui/` (`agenza-ui-primitive`). | Never let a field component or `shared/form/` read `problem.errors` directly — that parsing (indexed keys, the collapsed `""` key for a 409/404) lives in `formErrors.ts` alone. See `agenza-api-contract`'s @@ -26,12 +36,20 @@ keys, the collapsed `""` key for a 409/404) lives in `formErrors.ts` alone. See ## Wiring a new entity's form -1. **Schema** in that entity's `model/Form.ts`, mirroring `tagForm.ts`: +1. **Limits and invariants** in the slice's `model/.ts`, beside the entity's types, mirroring + the backend aggregate: plain constants (`TAG_NAME_MAX_LENGTH`, `MAX_GUARDIANS`) and pure functions + over the plain types (`needsGuardian(birthDate, guardianCount, today)`). No Zod, no pt-BR copy, no + class. Anything outside the form that needs the rule (a list badge, another page) imports it from + there, and so does the form's own UI when it shows the rule before submit. + + **Schema** in the page that owns the form, `ui/pages/FormPage/Form.ts`, mirroring + `tagForm.ts`, built from those limits and composing those invariants: ```ts - export const xFormSchema = z.object({ /* z.string().trim().min(1, '…') etc */ }); + export const xFormSchema = z.object({ + name: z.string().trim().min(1, '…').max(X_NAME_MAX_LENGTH, `… ${X_NAME_MAX_LENGTH} …`), + }); export type XFormFieldValues = z.input; export type XFormValues = z.output; - export const X_FORM_FIELDS = xFormSchema.keyof().options; export function toXFormFieldValues(x?: X): XFormFieldValues { return { name: x?.name ?? '', description: x?.description ?? '' }; @@ -39,8 +57,11 @@ keys, the collapsed `""` key for a 409/404) lives in `formErrors.ts` alone. See ``` **Use `z.input`/`z.output`, never a single `z.infer`, the moment the schema has a `.refine()` or `.transform()`.** See Gotcha 1 below — this is not optional once either is present. - `keyof().options` is already typed as the key union, so the model needs no `as` cast and no - `react-hook-form` import — `model/` stays library-free. `toXFormFieldValues` is the inverse of the + **No field list for errors**: `applyApiProblem` derives the paths from `methods.getValues()`, so + `toXFormFieldValues()` must return every field (the `z.input` return type already forces it). + Export `X_FORM_FIELDS = xFormSchema.keyof().options` only when something renders from it, like + the loading skeleton below; an export kept alive only by its own test is dead code. + `toXFormFieldValues` is the inverse of the schema's `.transform()`s (a `null` description back to `''`) and sits beside them: `useForm`'s `defaultValues` is `toXFormFieldValues()`, the edit fetch's `reset` is `toXFormFieldValues(entity)`. @@ -58,7 +79,7 @@ keys, the collapsed `""` key for a 409/404) lives in `formErrors.ts` alone. See async function onValid(values: XFormValues) { const result = isEdit ? await xRepository.update(id, values) : await xRepository.create(values); if (result.ok) { /* publish, toast, navigate */ return; } - applyApiProblem(result.error, X_FORM_FIELDS, methods.setError); + applyApiProblem(result.error, methods); } function submit(event: SubmitEvent) { @@ -74,20 +95,23 @@ keys, the collapsed `""` key for a 409/404) lives in `formErrors.ts` alone. See trips `@typescript-eslint/no-misused-promises`; `tsc` alone won't catch it, only `npm run lint`. `canSubmit` is the rule behind the **Salvar** button's `disabled` state. The form's - `onSubmit` calls `submit`. The footer uses: + `onSubmit` calls `submit`. The footer is a widget, chosen by where the form lives: ```tsx - }>Cancelar - - {isSaving ? 'Salvando…' : 'Salvar'} - + import { DialogFormFooter, PageFormFooter } from '@/widgets/form-footer'; + + + ``` - `ActionButton` turns `pending` into a spinner and disables the button. + `DialogFormFooter` goes inside a `Dialog` (its Cancelar is a `DialogClose`); `PageFormFooter` goes on + a full page and requires `cancelTo` (its Cancelar is a link that turns into a disabled button while + saving). Both render **Salvar**/**Salvando…** as an `ActionButton`, which turns `pending` into a + spinner and disables the button. **While saving, the dialog offers no way out** — a Cancelar clicked mid-request would close the dialog while the save still lands, a Cancel that doesn't cancel. Three lines, all with APIs the dialog already had: the hook's `onOpenChange` ignores a close while `isSaving` (a controlled dialog - stays open when its handler doesn't close it, which covers Esc and outside press), Cancelar is - ``, and the shell passes `showCloseButton={!isSaving}` to + stays open when its handler doesn't close it, which covers Esc and outside press), + `DialogFormFooter` disables its Cancelar, and the shell passes `showCloseButton={!isSaving}` to `DialogContent`. `shared/ui/dialog` itself stays a plain wrapper — a `busy` prop with a context was tried there and reverted as more machinery than three lines per dialog. @@ -101,9 +125,10 @@ keys, the collapsed `""` key for a 409/404) lives in `formErrors.ts` alone. See field-values type as an explicit JSX type argument: ```tsx import { ColorField, TextField } from '@/shared/form/fields'; - import type { XFormFieldValues } from '../../model/xForm'; + import { X_NAME_MAX_LENGTH } from '../../../../model/x'; + import type { XFormFieldValues } from '../xForm'; - name="name" label="Nome" hint="…" maxLength={40} placeholder="…" /> + name="name" label="Nome" hint="…" maxLength={X_NAME_MAX_LENGTH} /> name="color" label="Cor" aria-label="Cor…" options={PALETTE} /> ``` The type argument is what makes `name` type-check against `Path` — a bare @@ -111,7 +136,32 @@ keys, the collapsed `""` key for a 409/404) lives in `formErrors.ts` alone. See `FieldValues`), but silently accepts any string as `name`, losing the one thing worth keeping. A native-input field (`register()`-bound) is `TextField`/`TextareaField`. A custom control needs `ControlledField` (pass a render function) or a named specialization built on top of it, per the - rule below. + rule below. When the render function needs the value's own type, pass the path as the second type + argument and drop any cast: with + `` …>``, `field.value` is that + array, not the union of every value in the form. + +5. **A CPF, phone, e-mail or birth date is not configured by hand: reuse the pair.** The schema half + comes from that value's file in `@/shared/values/` (`cpfSchema` from `@/shared/values/cpf`, then + `phoneSchema`, `emailSchema`, `birthDateSchema`: an empty field becomes `null`, anything else is + parsed and fails with the pt-BR message), the + field half from `@/shared/form/fields` (`CpfField`, `PhoneField`, `EmailField`, `BirthDateField`: + mask, input type, limit, placeholder, default label and `opcional` hint already set, each one + overridable by prop): + ```ts + const personSchema = z.object({ phone: phoneSchema, cpf: cpfSchema }); + ``` + ```tsx + name="cpf" /> + name="phone" label="Telefone do contato" /> + ``` + The parsing underneath sits in the same file as the schema (`parseCpf`, `parsePhone`, `parseEmail`: + canonical value or `null`, mirroring the backend's `Create`), so never re-validate with a regex of + your own. A new value type (a CNPJ) is one file, `shared/values/cnpj.ts`, mirroring the backend's + value object: its `parseX`, its limits and a schema built with `optionalValue(parse)` (its + `fail(message)` becomes the Zod issue); plus, only if several forms will configure it the same way, + a preset field in `shared/form/fields/`. The schemas have no required variant yet; add it to `optionalValue` when the first + form needs one. ## Editing an existing entity: fetch fresh, don't trust what the list already had @@ -156,6 +206,11 @@ already applies to `entities/` and to not rebuilding `color-swatch-picker` befor (ARCHITECTURE §1, §5) — a one-off specialization for a control only `ControlledField` has ever wrapped is guessing at an API shape with a sample size of one. +The value presets (`CpfField`, `PhoneField`, `EmailField`, `BirthDateField`) are a different thing: +they are not custom controls but fixed configurations of `MaskedField`/`TextField`/`DateField`, kept in +one place because the same value was being configured by hand at several call sites (the phone three +times). Add another only for a value type that will be configured the same way in more than one form. + ## Two gotchas, both caught by `tsc`/review, not by a test **1. `z.infer` is not enough once a schema has `.refine()` or `.transform()`.** A schema's *input* @@ -167,7 +222,7 @@ TTransformedValues>` needs the input shape as its first generic, the output shap Modeling both as one `z.infer` type — the natural first guess — compiles right up until `resolver: zodResolver(schema)` is attached to `useForm`, where `tsc` names a `Resolver` mismatch. Fix: `z.input` for the field components and -`applyApiProblem`'s generic, `z.output` for `onValid`'s parameter — see +`useForm`'s first generic (`applyApiProblem` infers its own from `methods`), `z.output` for `onValid`'s parameter — see `TagFormFieldValues`/`TagFormValues` in `tagForm.ts`. **2. Not every control accepts `id`/`htmlFor`.** `FormField`'s `