From 5f11960c3aa35eaf69550363d9a4ca0ae8676d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 28 Jul 2026 08:05:45 -0300 Subject: [PATCH] B32+: share the campaign JSON route wrapper, fetch helper and cell auto-save. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `campaignJsonMutationRoute` now builds every JSON POST route under /campanha — `isSameOriginRequest` had been copied into five of them, so the CSRF policy was failing open by omission; a convention guard sweeps all of `src/app` and names its three exceptions. `postCampaignJson` is the only place that spells `credentials: 'same-origin'`, and `useCampaignCellAutosave` is the debounce / abort / revert / flush-on-close machine the quick-edit cells shipped three times. The shell keeps the `aria-live` region outside the Popover/Drawer, since closing is what commits and it was unmounting the node that would announce the result; `variant="sheet"` took a `footer` slot, which let the signal control drop the overlay it only kept for one button. Two bugs the extraction made visible: a revert issued while a different payload was in flight was dropped silently, and `change()` never cleared a past failure, which outranks everything the live region would otherwise say. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + AGENTS.md | 2 + docs/plans/escala-dry-pos-b32.md | 48 +++- docs/roadmap.md | 6 +- .../(app)/atividades/contactSearchActions.ts | 5 +- .../(app)/liderancas/support-status/route.ts | 36 +-- .../(app)/municipios/advisors/route.ts | 38 +-- .../municipios/engagement-level/route.ts | 90 +++--- .../(app)/municipios/expected-votes/route.ts | 38 +-- .../(app)/municipios/political-trend/route.ts | 37 +-- .../LeadershipListSupportStatusControl.tsx | 144 +--------- .../MunicipalityListAdvisorsControl.tsx | 34 +-- .../MunicipalityListExpectedVotesControl.tsx | 153 ++-------- .../MunicipalityListLevelControl.tsx | 81 +++--- .../MunicipalityListSignalControl.tsx | 168 ++++------- .../MunicipalityListTrendControl.tsx | 254 ++++------------- .../shared/CampaignCellEditOverlay.tsx | 113 +++++--- .../LeadershipStateDeputyRelationCell.tsx | 13 +- .../shared/MunicipalityPortfolioCell.tsx | 6 + .../shared/useCampaignCellAutosave.ts | 233 +++++++++++++++ .../shared/useCampaignCellFailureChannel.ts | 33 +++ src/lib/campaignJsonRequest.ts | 25 ++ src/utilities/campaignActionContext.ts | 3 +- src/utilities/campaignFormActionError.ts | 6 + src/utilities/campaignJsonMutationRoute.ts | 69 ++++- src/utilities/sameOriginRequest.ts | 4 +- tests/unit/campaignCellAutosave.unit.spec.ts | 269 ++++++++++++++++++ .../unit/campaignCellEditOverlay.unit.spec.ts | 156 +++++++++- .../campaignJsonMutationRoute.unit.spec.ts | 121 ++++++++ tests/unit/codebaseConventions.unit.spec.ts | 40 +++ 30 files changed, 1396 insertions(+), 830 deletions(-) create mode 100644 src/components/campaign/shared/useCampaignCellAutosave.ts create mode 100644 src/components/campaign/shared/useCampaignCellFailureChannel.ts create mode 100644 src/lib/campaignJsonRequest.ts create mode 100644 tests/unit/campaignCellAutosave.unit.spec.ts create mode 100644 tests/unit/campaignJsonMutationRoute.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 1f7fffd3..ffa644f1 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **B32+ entregue em código (2026-07-28):** escala e DRY pós-B32 (+564 / −802 linhas em 15 arquivos, mais quatro novos; sem migration e sem mudança de contrato de rede). Toda rota JSON de `/campanha` agora nasce de **`campaignJsonMutationRoute(config, handler)`** — origem verificada, corpo parseado e `catch` mapeado por construção, com o `bodySchema.parse` **dentro do mesmo `try`** para não trocar a mensagem de corpo inválido; `parseCampaignJsonRequestBody`/`campaignJsonMutationErrorResponse` deixaram de ser exportados e um **guard de convenção** em `codebaseConventions.unit.spec.ts` recusa um `POST` que não passe pelo wrapper. Isso existe porque a política de mesma origem estava copiada em cinco rotas: a que esquecesse a linha aceitaria POST cross-origin em silêncio. `engagement-level` mantém o 409 dentro do handler. No cliente, `postCampaignJson` (transporte) e **`useCampaignCellAutosave`** (`shared/`) absorvem os três controles de auto-save. Três achados da auditoria que valem mais que o refactor: `display`/`draft` **nunca divergiram** (eram setados juntos em todos os caminhos — o hook tem um valor só, e o `noteDraft` do Trend fica no chamador porque texto cru e nota normalizada são coisas diferentes); `reportFailure` **não** existia nos quatro controles como o plano afirmava (o de assessores não importava `sonner`); e `flush()` idempotente elimina na raiz o `inFlightRef` que o B24 tinha como contorno de dois caminhos para o mesmo save. O hook é dono do `open` — fechar É o commit —, e `valueRef` avança sincronamente para não reler o render que agendou a mudança (bug que B33+ e B34 já pagaram). Casca: a região `aria-live` mudou para **fora** do Popover/Drawer (prop `statusMessage`), já que o fechamento que dispara a gravação desmontava justamente o nó que anunciaria "Salvando…"; e `variant="sheet"` ganhou slot `footer`, que tirou "Registrar movimento" de baixo da dobra no controle de nível (E14) e permitiu ao Sinal (B26) largar o Popover/Drawer próprios — `formId` na casca acabou desnecessário, porque `
` no chamador + ` + ) return ( + {submitButton} + } + > + Cancelar + + + ) : undefined + } tooltipContent={ currentLevel ? (
@@ -339,19 +354,9 @@ export const MunicipalityListLevelControl = ({ {errorMessage} ) : null} - -

- {isPending ? 'Registrando nível.' : (errorMessage ?? '')} -

+ {/* On touch the same button is pinned to the footer, out of a body + that scrolls past it. */} + {isSheet ? null : submitButton}
) diff --git a/src/components/campaign/municipality/MunicipalityListSignalControl.tsx b/src/components/campaign/municipality/MunicipalityListSignalControl.tsx index 34c624cd..e10b2536 100644 --- a/src/components/campaign/municipality/MunicipalityListSignalControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListSignalControl.tsx @@ -5,23 +5,13 @@ import { toast } from 'sonner' import { MunicipalitySignalFields } from '@/components/campaign/municipality/MunicipalitySignalFields' import { - campaignCellEditTriggerClassName, + CampaignCellEditOverlay, type CampaignCellEditOverlayVariant, } from '@/components/campaign/shared/CampaignCellEditOverlay' import { Alert, AlertDescription } from '@/components/ui/Alert' import { Button } from '@/components/ui/button' -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, -} from '@/components/ui/Drawer' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' +import { DrawerClose } from '@/components/ui/Drawer' import { Spinner } from '@/components/ui/Spinner' -import { cn } from '@/lib/utils' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' import { formatMunicipalitySignalAgeLabel, @@ -43,41 +33,6 @@ type MunicipalityListSignalControlProps = { children: ReactNode } -const SignalFormFields = ({ - municipalityID, - municipalitySlug, - idPrefix, - state, - isPending, -}: { - municipalityID: number - municipalitySlug: string - idPrefix: string - state: CampaignFormActionState - isPending: boolean -}) => ( - <> - - - - {state.message && state.status !== 'success' ? ( - - {state.message} - - ) : null} - - {isPending ? 'Registrando sinal…' : ''} - - -) - -const SubmitButton = ({ isPending }: { isPending: boolean }) => ( - -) - export const MunicipalityListSignalControl = ({ municipalityID, municipalitySlug, @@ -92,8 +47,9 @@ export const MunicipalityListSignalControl = ({ const [state, submitAction, isPending] = useActionState(formAction, {}) const reactId = useId() const idPrefix = `municipality-list-signal-${variant}-${municipalityID}-${reactId}` + const formId = `${idPrefix}-form` + const isSheet = variant === 'sheet' const frescorLabel = formatMunicipalitySignalAgeLabel(municipalitySignalAgeInDays(lastSignalAt)) - const triggerLabel = `Registrar sinal em ${municipalityName} — ${frescorLabel}` useEffect(() => { if (state.status !== 'success') return @@ -102,71 +58,61 @@ export const MunicipalityListSignalControl = ({ setFormKey((key) => key + 1) }, [state.message, state.status]) - // This control keeps its own container (its `` has to wrap header, body - // and submit), so it borrows the shell's trigger instead of re-spelling it — - // `relative` included, which is what keeps the tap on the mobile card. - const triggerClassName = cn(campaignCellEditTriggerClassName, 'text-left') - const triggerProps = { - type: 'button', - 'aria-expanded': open, - 'aria-haspopup': 'dialog', - 'aria-busy': isPending || undefined, - className: triggerClassName, - 'aria-label': triggerLabel, - } as const - - const fields = ( - + const submitButton = ( + // On the sheet this button lives in the footer, outside the `` it + // submits: `form={formId}` is the standard association, and React's action + // still runs because the submit event fires on the form either way. That is + // what let this control drop the Drawer/Popover it used to hand-roll only + // because its `` had to wrap the footer (B32+ F5). + ) - if (variant === 'sheet') { - return ( - <> - - - - - - Registrar sinal - {municipalityName} - -
- {fields} -
- - - } - > - Cancelar - - - -
-
- - ) - } - return ( - - - - - -
- {fields} - - -
-
+ + {submitButton} + } + > + Cancelar + + + ) : undefined + } + > +
+ + + + {state.message && state.status !== 'success' ? ( + + {state.message} + + ) : null} + {isSheet ? null : submitButton} + +
) } diff --git a/src/components/campaign/municipality/MunicipalityListTrendControl.tsx b/src/components/campaign/municipality/MunicipalityListTrendControl.tsx index bf5e3896..4324ea12 100644 --- a/src/components/campaign/municipality/MunicipalityListTrendControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListTrendControl.tsx @@ -1,7 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' -import { toast } from 'sonner' +import { useEffect, useMemo, useState } from 'react' import type { MunicipalityListPoliticalTrendResponse, @@ -11,6 +10,7 @@ import { CampaignCellEditOverlay, type CampaignCellEditOverlayVariant, } from '@/components/campaign/shared/CampaignCellEditOverlay' +import { useCampaignCellAutosave } from '@/components/campaign/shared/useCampaignCellAutosave' import { Alert, AlertDescription } from '@/components/ui/Alert' import { Badge } from '@/components/ui/Badge' import { Field, FieldLabel } from '@/components/ui/field' @@ -51,197 +51,46 @@ export const MunicipalityListTrendControl = ({ trendNote, variant, }: MunicipalityListTrendControlProps) => { - const initialTrend: MunicipalityListSavedPoliticalTrend = { - status, - note: normalizeNote(trendNote), - } - const [open, setOpen] = useState(false) - const [displayTrend, setDisplayTrend] = useState(initialTrend) - const [draft, setDraft] = useState(initialTrend) + const { open, onOpenChange, value, change, flush, isPending, errorMessage, statusMessage } = + useCampaignCellAutosave< + MunicipalityListSavedPoliticalTrend, + MunicipalityListPoliticalTrendResponse + >({ + // Memoized because the hook's server-adoption effect keys on this + // reference; a fresh literal per render would run it (harmlessly, since + // `equals` guards the body) on every commit of the list. + value: useMemo(() => ({ status, note: normalizeNote(trendNote) }), [status, trendNote]), + equals: trendsEqual, + endpoint: POLITICAL_TREND_ENDPOINT, + buildBody: (trend) => ({ + municipalityId: municipalityID, + status: trend.status, + note: trend.note, + }), + readSaved: (payload) => payload.savedTrend, + errorMessage: SAVE_ERROR_MESSAGE, + pendingMessage: 'Salvando tendência.', + }) + + // The only state the hook cannot hold: the textarea's raw text, which the + // saved note is the trimmed form of. It follows the saved note whenever that + // changes for a reason other than this typing (adoption, revert, server echo) + // and is left alone while the two still agree, so trailing spaces survive. const [noteDraft, setNoteDraft] = useState(trendNote ?? '') - const [isDirty, setIsDirty] = useState(false) - const [isPending, setIsPending] = useState(false) - const [errorMessage, setErrorMessage] = useState(null) - const saveTimeoutRef = useRef | undefined>(undefined) - const abortRef = useRef(null) - const committedTrendRef = useRef(initialTrend) - const lastPropsTrendRef = useRef(initialTrend) - const saveGenerationRef = useRef(0) - /** Skip abort+re-POST when blur and popover-close flush the same payload. */ - const inFlightTrendRef = useRef(null) - const openRef = useRef(false) - - /** - * Closing is what commits the draft, and closing also unmounts the Alert and - * the live region that would carry a failure — so a save that fails on the - * way out reverts the badge with nobody told. A toast outlives the overlay; - * while it is still open the inline Alert remains the closer channel. - */ - const reportFailure = (message: string) => { - setErrorMessage(message) - if (!openRef.current) toast.error(message) - } - - const adoptTrend = (trend: MunicipalityListSavedPoliticalTrend) => { - setDisplayTrend(trend) - setDraft(trend) - setNoteDraft(trend.note ?? '') - } - - // Adopt server props only when they change from outside (navigation / RSC refresh). useEffect(() => { - const next: MunicipalityListSavedPoliticalTrend = { - status, - note: normalizeNote(trendNote), - } - if (trendsEqual(next, lastPropsTrendRef.current)) return - lastPropsTrendRef.current = next - committedTrendRef.current = next - adoptTrend(next) - setIsDirty(false) - }, [status, trendNote]) + setNoteDraft((current) => + normalizeNote(current) === value.note ? current : (value.note ?? ''), + ) + }, [value.note]) - useEffect( - () => () => { - clearTimeout(saveTimeoutRef.current) - abortRef.current?.abort() - }, - [], - ) - - const saveDraft = async (values: MunicipalityListSavedPoliticalTrend) => { - const normalized: MunicipalityListSavedPoliticalTrend = { - status: values.status, - note: normalizeNote(values.note), - } - if (trendsEqual(normalized, committedTrendRef.current)) return - if (inFlightTrendRef.current && trendsEqual(normalized, inFlightTrendRef.current)) return - - const generation = ++saveGenerationRef.current - abortRef.current?.abort() - const controller = new AbortController() - abortRef.current = controller - inFlightTrendRef.current = normalized - - setIsPending(true) - setErrorMessage(null) - - try { - const response = await fetch(POLITICAL_TREND_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - credentials: 'same-origin', - signal: controller.signal, - body: JSON.stringify({ - municipalityId: municipalityID, - status: normalized.status, - note: normalized.note, - }), - }) - - const payload = (await response.json()) as MunicipalityListPoliticalTrendResponse - - if (generation !== saveGenerationRef.current) return - - if (!response.ok || payload.status !== 'success') { - adoptTrend(committedTrendRef.current) - setIsDirty(false) - reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE) - return - } - - committedTrendRef.current = payload.savedTrend - adoptTrend(payload.savedTrend) - setIsDirty(false) - } catch { - if (controller.signal.aborted || generation !== saveGenerationRef.current) return - adoptTrend(committedTrendRef.current) - setIsDirty(false) - reportFailure(SAVE_ERROR_MESSAGE) - } finally { - if (generation === saveGenerationRef.current) { - inFlightTrendRef.current = null - setIsPending(false) - } - } - } - - const scheduleSave = (values: MunicipalityListSavedPoliticalTrend, delayMs: number) => { - if (trendsEqual(values, committedTrendRef.current)) { - clearTimeout(saveTimeoutRef.current) - setIsDirty(false) - return - } - clearTimeout(saveTimeoutRef.current) - saveTimeoutRef.current = setTimeout(() => { - void saveDraft(values) - }, delayMs) - } - - const handleStatusChange = (raw: string) => { - const next: MunicipalityListSavedPoliticalTrend = { - status: parsePoliticalTrendStatusFormValue(raw || undefined), - note: normalizeNote(noteDraft), - } - setIsDirty(true) - setDraft(next) - setDisplayTrend(next) - scheduleSave(next, STATUS_AUTOSAVE_MS) - } - - const handleNoteChange = (raw: string) => { - const next: MunicipalityListSavedPoliticalTrend = { - status: draft.status, - note: normalizeNote(raw), - } - setNoteDraft(raw) - setIsDirty(true) - setDraft(next) - setDisplayTrend(next) - scheduleSave(next, NOTE_AUTOSAVE_MS) - } - - const flushDraft = () => { - clearTimeout(saveTimeoutRef.current) - const next: MunicipalityListSavedPoliticalTrend = { - status: draft.status, - note: normalizeNote(noteDraft), - } - if (!trendsEqual(next, committedTrendRef.current)) { - void saveDraft(next) - } - setIsDirty(false) - } - - const handleOpenChange = (nextOpen: boolean) => { - openRef.current = nextOpen - if (nextOpen) { - setIsDirty(false) - setErrorMessage(null) - adoptTrend(displayTrend) - } else if (open) { - flushDraft() - } - setOpen(nextOpen) - } - - const hasNote = Boolean(displayTrend.note) - const trendLabel = displayTrend.status - ? politicalTrendLabels[displayTrend.status] - : 'Não registrada' - const statusMessage = errorMessage - ? errorMessage - : isPending - ? 'Salvando tendência.' - : isDirty - ? 'Alterações serão salvas automaticamente.' - : '' + const hasNote = Boolean(value.note) + const trendLabel = value.status ? politicalTrendLabels[value.status] : 'Não registrada' return ( {displayTrend.note}

: null} + statusMessage={statusMessage} + tooltipContent={hasNote ?

{value.note}

: null} contentClassName="w-72 p-3" preventPopoverAutoFocus trigger={ - displayTrend.status ? ( - {trendLabel} + value.status ? ( + {trendLabel} ) : ( {trendLabel} ) @@ -272,8 +122,16 @@ export const MunicipalityListTrendControl = ({ Tendência handleStatusChange(event.target.value)} + value={value.status ?? ''} + onChange={(event) => + change( + (current) => ({ + ...current, + status: parsePoliticalTrendStatusFormValue(event.target.value || undefined), + }), + STATUS_AUTOSAVE_MS, + ) + } className="min-h-11 w-full" > Não registrada @@ -293,8 +151,17 @@ export const MunicipalityListTrendControl = ({