From b210939226bd32c19dc3f44b63872599e627a2a1 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 09:57:50 -0300 Subject: [PATCH] =?UTF-8?q?refactor(B193):=20simplify=20fixes=20p=C3=B3s-r?= =?UTF-8?q?evis=C3=A3o=20=E2=80=94=20mapper=20=C3=BAnico=20de=20updates,?= =?UTF-8?q?=20API=20de=20trigger=20uniforme,=20grupos=20de=20rela=C3=A7?= =?UTF-8?q?=C3=A3o=20extra=C3=ADdos,=20hardening=20do=20sheet=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extrai toMunicipalityUpdateViewModel/loadUpdateAuthorNames compartilhados (feed + batch da lista); remove avatarClassName morto. - MunicipalityRelationTriggerProps compartilhado nos 3 wrappers; triggerClassName no ExpectedVotesControl; ChipLabel/RelationGroup/CityDash no card. - Sheet host: cleanup de unmount no overlay (sem drawer órfão), registerFooter valida open, branch nulo do handleOpenChange limpa footer; chrome freeze documentado. - A11y/UX: ícone de alerta no rodapé frio (WCAG 1.4.1), iniciais de avatar aria-hidden, chip sem_base sem target morto, label da cidade com Tendência, aria-label da barra anuncia os 3 cenários, card layout 'colocação · votos'. - E2E: settleStream no B184 (flake da cópia transitória do streaming), cards ancorados por hasText (q= casa prefixos), cor da borda via classe do token. --- .../MunicipalityListAdvisorsControl.tsx | 8 +- .../MunicipalityListExpectedVotesControl.tsx | 20 +++-- .../MunicipalityListLeadershipsControl.tsx | 8 +- .../MunicipalityListRowReadouts.tsx | 34 ++++---- .../municipality/MunicipalityMobileCard.tsx | 70 ++++++++++------ .../MunicipalityVotePositionReadout.tsx | 6 +- .../shared/CampaignCellEditOverlay.tsx | 25 +++++- .../campaign/shared/CampaignListSheetHost.tsx | 8 +- .../MunicipalityRelationAvatarStack.tsx | 12 +-- .../shared/MunicipalityRelationEditor.tsx | 22 ++--- .../MunicipalityStateDeputyRelationCell.tsx | 8 +- .../municipalityUpdatePageData.ts | 81 +++++++++---------- tests/e2e/campaignMunicipalities.e2e.spec.ts | 39 +++++++-- .../unit/campaignCellEditOverlay.unit.spec.ts | 4 +- tests/unit/campaignComponents.unit.spec.ts | 4 + 15 files changed, 215 insertions(+), 134 deletions(-) diff --git a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx index 21104f1c..5520e328 100644 --- a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx @@ -8,18 +8,18 @@ import type { MunicipalityRelationEntry } from '@/components/campaign/shared/Mun import { MunicipalityRelationEditor, type MunicipalityRelationMutationResult, + type MunicipalityRelationTriggerProps, } from '@/components/campaign/shared/MunicipalityRelationEditor' import { postCampaignJson } from '@/lib/campaignJsonRequest' import type { EligibleAdvisorOption, MunicipalityAdvisorSummary, } from '@/utilities/municipality/municipalityViewModels' -import type { ReactNode } from 'react' const ADVISORS_ENDPOINT = '/campanha/municipios/advisors' const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar os assessores. Tente novamente.' -type MunicipalityListAdvisorsControlProps = { +type MunicipalityListAdvisorsControlProps = MunicipalityRelationTriggerProps & { municipalityID: number municipalityName: string currentAdvisorIDs: number[] @@ -27,10 +27,6 @@ type MunicipalityListAdvisorsControlProps = { advisorNamesById: ReadonlyMap options: EligibleAdvisorOption[] variant: CampaignCellEditOverlayVariant - /** B193 — dense mobile card trigger (labelled wrapping stack). */ - trigger?: (entries: MunicipalityRelationEntry[], emptyState: ReactNode) => ReactNode - /** B193 — dense card trigger styling override. */ - triggerClassName?: string } const entryOf = (option: { id: number; name: string }): MunicipalityRelationEntry => ({ diff --git a/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx b/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx index b9ffe674..fefc3265 100644 --- a/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx @@ -17,6 +17,7 @@ import { Spinner } from '@/components/ui/Spinner' import { formatElectionNumber } from '@/lib/electionFormat' import { DEFAULT_VOTE_ESTIMATE_SCENARIO, + VOTE_ESTIMATE_SCENARIOS, voteEstimateScenarioLabels, voteEstimatesEqual, type VoteEstimateScenario, @@ -50,6 +51,8 @@ type MunicipalityListExpectedVotesControlProps = { value: VoteEstimateScenarioViewModel, activeScenario: VoteEstimateScenario, ) => ReactNode + /** B193 — dense card styling override (same slot as the sibling controls). */ + triggerClassName?: string } & ( | { variant: 'popover'; municipalityName?: string } | { variant: 'sheet'; municipalityName: string } @@ -62,6 +65,7 @@ export const MunicipalityListExpectedVotesControl = ({ pledgeCoverage, variant, trigger, + triggerClassName, }: MunicipalityListExpectedVotesControlProps) => { const scenarioContext = useMunicipalityEstimateScenarioOptional() const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO @@ -79,13 +83,19 @@ export const MunicipalityListExpectedVotesControl = ({ // An `aria-label` replaces the trigger's content, and the content here is the // number itself — so the label has to carry it, and name the scenario it - // belongs to (the picker can disagree with the column's sort, E9). - const activeValue = value[activeScenario] + // belongs to (the picker can disagree with the column's sort, E9). All three + // scenarios ride along: the strip shows the whole interval (B193), so the + // announcement must too. + const scenarioSummary = VOTE_ESTIMATE_SCENARIOS.map((scenario) => { + const scenarioValue = value[scenario] + return `${voteEstimateScenarioLabels[scenario]}: ${ + scenarioValue == null ? 'sem estimativa' : formatElectionNumber(scenarioValue) + }` + }).join('; ') const triggerLabel = [ 'Editar votos estimados', municipalityName ? ` em ${municipalityName}` : '', - ` — ${voteEstimateScenarioLabels[activeScenario]}: `, - activeValue == null ? 'sem estimativa' : formatElectionNumber(activeValue), + ` — ${scenarioSummary}`, ].join('') return ( @@ -98,7 +108,7 @@ export const MunicipalityListExpectedVotesControl = ({ triggerLabel={triggerLabel} triggerBusy={isPending} statusMessage={statusMessage} - triggerClassName="group flex w-full items-center justify-center" + triggerClassName={triggerClassName ?? 'group flex w-full items-center justify-center'} align="center" contentClassName="w-[15.5rem] p-3" preventPopoverAutoFocus diff --git a/src/components/campaign/municipality/MunicipalityListLeadershipsControl.tsx b/src/components/campaign/municipality/MunicipalityListLeadershipsControl.tsx index 1ae6ba2a..f8f41793 100644 --- a/src/components/campaign/municipality/MunicipalityListLeadershipsControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListLeadershipsControl.tsx @@ -7,28 +7,24 @@ import type { MunicipalityRelationEntry } from '@/components/campaign/shared/Mun import { MunicipalityRelationEditor, type MunicipalityRelationMutationResult, + type MunicipalityRelationTriggerProps, } from '@/components/campaign/shared/MunicipalityRelationEditor' import { postCampaignJson } from '@/lib/campaignJsonRequest' import type { EligibleLeadershipOption, MunicipalityLeadershipSummary, } from '@/utilities/municipality/municipalityViewModels' -import type { ReactNode } from 'react' const LEADERSHIPS_ENDPOINT = '/campanha/municipios/leaderships' const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar as lideranças. Tente novamente.' -type MunicipalityListLeadershipsControlProps = { +type MunicipalityListLeadershipsControlProps = MunicipalityRelationTriggerProps & { municipalityID: number municipalityName: string currentLeadershipIDs: number[] leadershipNamesById: ReadonlyMap options: EligibleLeadershipOption[] variant: CampaignCellEditOverlayVariant - /** B193 — dense mobile card trigger (labelled wrapping stack). */ - trigger?: (entries: MunicipalityRelationEntry[], emptyState: ReactNode) => ReactNode - /** B193 — dense card trigger styling override. */ - triggerClassName?: string } const entryOf = (option: { id: number; name: string }): MunicipalityRelationEntry => ({ diff --git a/src/components/campaign/municipality/MunicipalityListRowReadouts.tsx b/src/components/campaign/municipality/MunicipalityListRowReadouts.tsx index 76cfa2da..7d59b31d 100644 --- a/src/components/campaign/municipality/MunicipalityListRowReadouts.tsx +++ b/src/components/campaign/municipality/MunicipalityListRowReadouts.tsx @@ -90,17 +90,23 @@ export const TerritorialClassCardReadout = ({ municipality, }: { municipality: MunicipalityListViewModel -}) => ( - - - - - -) +}) => { + // `sem_base` has no reason to explain: the wrapper stays UNpositioned, so a + // tap on the dash falls through to the município link (tap-through + // contract) instead of intercepting it with a dead tooltip target. + const positioned = municipality.territorialClass !== 'sem_base' + return ( + + + + + + ) +} diff --git a/src/components/campaign/municipality/MunicipalityMobileCard.tsx b/src/components/campaign/municipality/MunicipalityMobileCard.tsx index 884d3a3c..4187afd8 100644 --- a/src/components/campaign/municipality/MunicipalityMobileCard.tsx +++ b/src/components/campaign/municipality/MunicipalityMobileCard.tsx @@ -1,6 +1,6 @@ 'use client' -import { ChevronDownIcon } from 'lucide-react' +import { ChevronDownIcon, CircleAlertIcon } from 'lucide-react' import Link from 'next/link' import { useState, type ReactNode } from 'react' @@ -87,7 +87,12 @@ const chipTriggerClassName = 'min-h-8 rounded-full px-2.5' /** Ghost trigger styling for the read-only relation groups (no hover pill). */ const relationTriggerClassName = 'min-h-0 rounded-md bg-transparent px-0 hover:bg-transparent' -const chipLabel = 'text-xs font-medium text-muted-foreground' +const ChipLabel = ({ children }: { children: ReactNode }) => ( + {children} +) + +/** Read-only city rows render a plain dash everywhere a control would sit. */ +const CityDash = () => — const RelationGroupTrigger = ({ label, @@ -99,11 +104,26 @@ const RelationGroupTrigger = ({ emptyState: ReactNode }) => (
- {label} + {label}
) +/** + * One third of the avatar row: the label + the relation control (or the + * read-only display) for a single association type. + */ +const RelationGroup = ({ label, children }: { label: string; children: ReactNode }) => ( +
+ {children ?? ( +
+ {label} + +
+ )} +
+) + /** * B193 — the dense mobile municipality card: header with the 2022 vote * position on the right, the estimate scenario bar with the active marker, @@ -226,7 +246,10 @@ export const MunicipalityMobileCard = ({
{isCity ? ( - Não registrada + + Tendência + Não registrada + ) : ( ( <> - Tendência + Tendência @@ -260,14 +283,14 @@ export const MunicipalityMobileCard = ({ triggerClassName={chipTriggerClassName} trigger={(level, note) => ( <> - Nível + Nível )} /> ) : ( - Nível + Nível
-
- {isCity ? ( - — - ) : isCoordinator ? ( + + {isCity ? null : isCoordinator ? ( ) : (
- Assessores + Assessores
)} -
-
- {isCity ? ( - — - ) : ( + + + {isCity ? null : ( )} -
-
- {isCity ? ( - — - ) : ( + + + {isCity ? null : ( )} -
+
{isCity ? ( - — + ) : update ? ( <>