mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
refactor(B193): simplify fixes pós-revisão — mapper único de updates, API de trigger uniforme, grupos de relação extraídos, hardening do sheet host
- 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.
This commit is contained in:
parent
3d8dbae61f
commit
b210939226
15 changed files with 215 additions and 134 deletions
|
|
@ -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<number, MunicipalityAdvisorSummary>
|
||||
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 => ({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<number, MunicipalityLeadershipSummary>
|
||||
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 => ({
|
||||
|
|
|
|||
|
|
@ -90,17 +90,23 @@ export const TerritorialClassCardReadout = ({
|
|||
municipality,
|
||||
}: {
|
||||
municipality: MunicipalityListViewModel
|
||||
}) => (
|
||||
<CampaignHoverTooltip
|
||||
content={
|
||||
municipality.territorialClass === 'sem_base'
|
||||
? null
|
||||
: formatTerritorialClassWhy(municipality.territorialClassFactors)
|
||||
}
|
||||
align="start"
|
||||
>
|
||||
<span className="relative inline-flex items-center">
|
||||
<TerritorialClassReadout municipality={municipality} />
|
||||
</span>
|
||||
</CampaignHoverTooltip>
|
||||
)
|
||||
}) => {
|
||||
// `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 (
|
||||
<CampaignHoverTooltip
|
||||
content={
|
||||
municipality.territorialClass === 'sem_base'
|
||||
? null
|
||||
: formatTerritorialClassWhy(municipality.territorialClassFactors)
|
||||
}
|
||||
align="start"
|
||||
>
|
||||
<span className={cn('inline-flex items-center', positioned && 'relative')}>
|
||||
<TerritorialClassReadout municipality={municipality} />
|
||||
</span>
|
||||
</CampaignHoverTooltip>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<span className="text-xs font-medium text-muted-foreground">{children}</span>
|
||||
)
|
||||
|
||||
/** Read-only city rows render a plain dash everywhere a control would sit. */
|
||||
const CityDash = () => <span className="text-muted-foreground">—</span>
|
||||
|
||||
const RelationGroupTrigger = ({
|
||||
label,
|
||||
|
|
@ -99,11 +104,26 @@ const RelationGroupTrigger = ({
|
|||
emptyState: ReactNode
|
||||
}) => (
|
||||
<div className="flex w-full min-w-0 flex-col gap-1.5">
|
||||
<span className={chipLabel}>{label}</span>
|
||||
<ChipLabel>{label}</ChipLabel>
|
||||
<MunicipalityRelationAvatarStack entries={entries} emptyState={emptyState} wrap />
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* 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 }) => (
|
||||
<div className="min-w-0 flex-1">
|
||||
{children ?? (
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<ChipLabel>{label}</ChipLabel>
|
||||
<CityDash />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* 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 = ({
|
|||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<TerritorialClassCardReadout municipality={municipality} />
|
||||
{isCity ? (
|
||||
<Badge variant="outline">Não registrada</Badge>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ChipLabel>Tendência</ChipLabel>
|
||||
<Badge variant="outline">Não registrada</Badge>
|
||||
</span>
|
||||
) : (
|
||||
<MunicipalityListTrendControl
|
||||
municipalityID={municipality.id}
|
||||
|
|
@ -237,7 +260,7 @@ export const MunicipalityMobileCard = ({
|
|||
triggerClassName={chipTriggerClassName}
|
||||
trigger={(trend) => (
|
||||
<>
|
||||
<span className={chipLabel}>Tendência</span>
|
||||
<ChipLabel>Tendência</ChipLabel>
|
||||
<Badge
|
||||
variant={trend.status ? politicalTrendBadgeVariant[trend.status] : 'outline'}
|
||||
>
|
||||
|
|
@ -260,14 +283,14 @@ export const MunicipalityMobileCard = ({
|
|||
triggerClassName={chipTriggerClassName}
|
||||
trigger={(level, note) => (
|
||||
<>
|
||||
<span className={chipLabel}>Nível</span>
|
||||
<ChipLabel>Nível</ChipLabel>
|
||||
<MunicipalityLevelBadge level={level} note={note} layout="table" />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={chipLabel}>Nível</span>
|
||||
<ChipLabel>Nível</ChipLabel>
|
||||
<MunicipalityLevelBadge
|
||||
level={municipality.engagementLevel}
|
||||
note={municipality.levelNote}
|
||||
|
|
@ -278,10 +301,8 @@ export const MunicipalityMobileCard = ({
|
|||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{isCity ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
) : isCoordinator ? (
|
||||
<RelationGroup label="Assessores">
|
||||
{isCity ? null : isCoordinator ? (
|
||||
<MunicipalityListAdvisorsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
|
|
@ -301,7 +322,7 @@ export const MunicipalityMobileCard = ({
|
|||
/>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<span className={chipLabel}>Assessores</span>
|
||||
<ChipLabel>Assessores</ChipLabel>
|
||||
<MunicipalityAdvisorAvatarStack
|
||||
advisors={advisors}
|
||||
isPriority={isPriority}
|
||||
|
|
@ -309,11 +330,9 @@ export const MunicipalityMobileCard = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isCity ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
) : (
|
||||
</RelationGroup>
|
||||
<RelationGroup label="Lideranças">
|
||||
{isCity ? null : (
|
||||
<MunicipalityListLeadershipsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
|
|
@ -331,11 +350,9 @@ export const MunicipalityMobileCard = ({
|
|||
triggerClassName={relationTriggerClassName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isCity ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
) : (
|
||||
</RelationGroup>
|
||||
<RelationGroup label="Dobradinhas">
|
||||
{isCity ? null : (
|
||||
<MunicipalityStateDeputyRelationCell
|
||||
municipalityId={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
|
|
@ -354,11 +371,11 @@ export const MunicipalityMobileCard = ({
|
|||
triggerClassName={relationTriggerClassName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</RelationGroup>
|
||||
</div>
|
||||
|
||||
{isCity ? (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
<CityDash />
|
||||
) : update ? (
|
||||
<>
|
||||
<button
|
||||
|
|
@ -369,12 +386,15 @@ export const MunicipalityMobileCard = ({
|
|||
>
|
||||
<span
|
||||
className={cn(
|
||||
'tabular-nums',
|
||||
'inline-flex items-center gap-1 tabular-nums',
|
||||
updateIsCold
|
||||
? 'font-medium text-estimate-pending-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{updateIsCold ? (
|
||||
<CircleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : null}
|
||||
Última atualização {formatMunicipalitySignalAgeLabel(updateAge)}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
|
|
|
|||
|
|
@ -20,10 +20,14 @@ export const MunicipalityVotePositionReadout = ({
|
|||
}) => {
|
||||
const share = formatVoteSharePercent(position.share)
|
||||
const rank = formatPlacementOrdinal(position.rank)
|
||||
// B193 — the dense card reads "colocação · votos" per the wireframe; the
|
||||
// table keeps "votos · colocação" (desktop untouched by B193).
|
||||
const metaLine =
|
||||
layout === 'search'
|
||||
? formatElectionNumber(position.votes)
|
||||
: `${formatElectionNumber(position.votes)} · ${rank}`
|
||||
: layout === 'card'
|
||||
? `${rank} · ${formatElectionNumber(position.votes)}`
|
||||
: `${formatElectionNumber(position.votes)} · ${rank}`
|
||||
const ariaLabel = `${share} da votação estadual, ${formatElectionNumber(position.votes)} votos, ${rank} de ${formatElectionNumber(position.totalUnits)}`
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -111,6 +111,26 @@ export const CampaignCellEditOverlay = ({
|
|||
const wasOpenRef = useRef(open)
|
||||
/** C109 — the shared drawer session this control opened (see the host). */
|
||||
const sessionIdRef = useRef<number | null>(null)
|
||||
// Latest values for the mount-only cleanup below (never stale, no deps).
|
||||
const sharedSheetRef = useRef(sharedSheet)
|
||||
sharedSheetRef.current = sharedSheet
|
||||
const onOpenChangeRef = useRef(onOpenChange)
|
||||
onOpenChangeRef.current = onOpenChange
|
||||
|
||||
// The control may unmount with its session open (a filter collapse behind
|
||||
// the modal): dismiss the host so no orphan drawer keeps a frozen chrome
|
||||
// and a dead body. The host's session guard makes the call a no-op when
|
||||
// another control already took over.
|
||||
useEffect(
|
||||
() => () => {
|
||||
const sheet = sharedSheetRef.current
|
||||
const sessionId = sessionIdRef.current
|
||||
if (sessionId !== null && sheet) {
|
||||
sheet.dismissSheet(sessionId, onOpenChangeRef.current)
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Miss #52: without the provider every opened cell keeps its own Drawer
|
||||
// root. The fallback below stays for prod resilience, but in dev the
|
||||
|
|
@ -191,7 +211,10 @@ export const CampaignCellEditOverlay = ({
|
|||
// Only the false→true transition opens a session: the controls' callbacks
|
||||
// are recreated per render (autosave `onOpenChange`, level `handleOpenChange`),
|
||||
// and calling `openSheet` on every re-render made the host dismiss the
|
||||
// session it just started (C109 — "sheet quebra ao abrir").
|
||||
// session it just started (C109 — "sheet quebra ao abrir"). Consequence:
|
||||
// `title`/`description`/`hasCustomFooter` are frozen while the drawer is
|
||||
// open — every caller passes static chrome, and the live parts (footer
|
||||
// buttons/spinner) flow through `setFooterContent` below.
|
||||
if (!wasOpen) {
|
||||
sessionIdRef.current = sharedSheet.openSheet({
|
||||
title,
|
||||
|
|
|
|||
|
|
@ -126,10 +126,13 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode })
|
|||
)
|
||||
|
||||
const registerFooter = useCallback((sessionId: number, footer: ReactNode | null) => {
|
||||
if (activeSessionRef.current !== sessionId) return
|
||||
if (activeSessionRef.current !== sessionId || !openRef.current) return
|
||||
setFooterContent(footer)
|
||||
}, [])
|
||||
|
||||
const openRef = useRef(open)
|
||||
openRef.current = open
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (nextOpen) return
|
||||
|
|
@ -137,7 +140,10 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode })
|
|||
const onOpenChange = activeOnOpenChangeRef.current
|
||||
if (sessionId === null || !onOpenChange) {
|
||||
setOpen(false)
|
||||
activeSessionRef.current = null
|
||||
activeOnOpenChangeRef.current = null
|
||||
setChrome(null)
|
||||
setFooterContent(null)
|
||||
return
|
||||
}
|
||||
dismissSheet(sessionId, onOpenChange)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ export const MunicipalityRelationAvatarStack = ({
|
|||
emptyState,
|
||||
maxVisible = 3,
|
||||
wrap = false,
|
||||
avatarClassName,
|
||||
}: {
|
||||
entries: MunicipalityRelationEntry[]
|
||||
emptyState: ReactNode
|
||||
|
|
@ -28,7 +27,6 @@ export const MunicipalityRelationAvatarStack = ({
|
|||
* row wraps to the available width instead of overlapping in a fixed stack.
|
||||
*/
|
||||
wrap?: boolean
|
||||
avatarClassName?: string
|
||||
}) => {
|
||||
if (entries.length === 0) return emptyState
|
||||
|
||||
|
|
@ -40,13 +38,11 @@ export const MunicipalityRelationAvatarStack = ({
|
|||
{visible.map((entry) => (
|
||||
<Avatar
|
||||
key={entry.id}
|
||||
className={cn(
|
||||
'size-8 border-2 border-background',
|
||||
wrap && 'size-7 border-0',
|
||||
avatarClassName,
|
||||
)}
|
||||
className={cn('size-8 border-2 border-background', wrap && 'size-7 border-0')}
|
||||
>
|
||||
<AvatarFallback>
|
||||
{/* The full names ride in the sr-only span below (or the trigger's
|
||||
aria-label); initials alone would be announced twice. */}
|
||||
<AvatarFallback aria-hidden="true">
|
||||
{campaignUserInitials(entry.initialsLabel ?? entry.label)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,17 @@ export type MunicipalityRelationMutationResult =
|
|||
}
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
type MunicipalityRelationEditorProps = {
|
||||
/**
|
||||
* B193 — the closed-display override the dense mobile card passes through the
|
||||
* relation wrappers (Advisors/Lideranças/Dobradinhas) to this editor.
|
||||
*/
|
||||
export type MunicipalityRelationTriggerProps = {
|
||||
trigger?: (entries: MunicipalityRelationEntry[], emptyState: ReactNode) => ReactNode
|
||||
/** B193 — dense card trigger styling override (no min-height/hover pill). */
|
||||
triggerClassName?: string
|
||||
}
|
||||
|
||||
type MunicipalityRelationEditorProps = MunicipalityRelationTriggerProps & {
|
||||
municipalityName: string
|
||||
currentIDs: number[]
|
||||
knownEntries?: MunicipalityRelationEntry[]
|
||||
|
|
@ -44,16 +54,6 @@ type MunicipalityRelationEditorProps = {
|
|||
createErrorMessage?: string
|
||||
triggerLabel: (entries: MunicipalityRelationEntry[]) => string
|
||||
emptyState: ReactNode
|
||||
/**
|
||||
* B193 — replaces the default avatar-stack trigger (e.g. the dense mobile
|
||||
* card's labelled, wrapping stack). Same pattern as `RelationChipCell`'s
|
||||
* `trigger` render prop: the overlay machinery stays, only the closed
|
||||
* display changes. Receives the relation's own `emptyState` so the custom
|
||||
* trigger can render the same absence affordance the default stack would.
|
||||
*/
|
||||
trigger?: (entries: MunicipalityRelationEntry[], emptyState: ReactNode) => ReactNode
|
||||
/** B193 — dense card trigger styling override (no min-height/hover pill). */
|
||||
triggerClassName?: string
|
||||
createLabel: (name: string) => string
|
||||
createMaxLength?: number
|
||||
sortSelected?: boolean
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ import type { MunicipalityRelationEntry } from '@/components/campaign/shared/Mun
|
|||
import {
|
||||
MunicipalityRelationEditor,
|
||||
type MunicipalityRelationMutationResult,
|
||||
type MunicipalityRelationTriggerProps,
|
||||
} from '@/components/campaign/shared/MunicipalityRelationEditor'
|
||||
import { stateDeputyDisplayName } from '@/lib/stateDeputyNameParty'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
|
||||
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type MunicipalityStateDeputyRelationCellProps = {
|
||||
type MunicipalityStateDeputyRelationCellProps = MunicipalityRelationTriggerProps & {
|
||||
municipalityId: number
|
||||
municipalityName: string
|
||||
stateDeputyIDs: number[]
|
||||
|
|
@ -24,10 +24,6 @@ type MunicipalityStateDeputyRelationCellProps = {
|
|||
) => Promise<CampaignFormActionState>
|
||||
createAction: MunicipalityStateDeputyCreateAction
|
||||
editorVariant: CampaignCellEditOverlayVariant
|
||||
/** B193 — dense mobile card trigger (labelled wrapping stack). */
|
||||
trigger?: (entries: MunicipalityRelationEntry[], emptyState: ReactNode) => ReactNode
|
||||
/** B193 — dense card trigger styling override. */
|
||||
triggerClassName?: string
|
||||
}
|
||||
|
||||
export type MunicipalityStateDeputyCreateAction = (
|
||||
|
|
|
|||
|
|
@ -38,6 +38,39 @@ export const parseMunicipalityUpdateFeedParams = (
|
|||
return { page }
|
||||
}
|
||||
|
||||
/** One doc → view-model mapping, shared by the feed and the list batch (B193). */
|
||||
const toMunicipalityUpdateViewModel = (
|
||||
update: MunicipalityUpdate,
|
||||
authorNameById: ReadonlyMap<number, string>,
|
||||
): MunicipalityUpdateViewModel => {
|
||||
const polarity = update.polarity as MunicipalityUpdatePolarity | undefined
|
||||
return {
|
||||
id: update.id,
|
||||
authorName: authorNameById.get(relationshipId(update.author) ?? -1) ?? 'Usuário',
|
||||
createdAt: update.createdAt,
|
||||
body: update.body ?? null,
|
||||
polarity: polarity && municipalityUpdatePolarities.includes(polarity) ? polarity : 'neutra',
|
||||
urgent: Boolean(update.urgent),
|
||||
activeVolunteers: update.activeVolunteers ?? null,
|
||||
newSupports: update.newSupports ?? null,
|
||||
adversarySignal: Boolean(update.adversarySignal),
|
||||
}
|
||||
}
|
||||
|
||||
const loadUpdateAuthorNames = async (
|
||||
payload: Payload,
|
||||
updates: MunicipalityUpdate[],
|
||||
): Promise<ReadonlyMap<number, string>> => {
|
||||
const authorIDs = [
|
||||
...new Set(
|
||||
updates
|
||||
.map((update) => relationshipId(update.author))
|
||||
.filter((id): id is number => id !== null),
|
||||
),
|
||||
]
|
||||
return loadCampaignUserNamesByIds(payload, authorIDs)
|
||||
}
|
||||
|
||||
export const loadMunicipalityUpdatesFeed = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
|
|
@ -62,31 +95,11 @@ export const loadMunicipalityUpdatesFeed = async (
|
|||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const authorIDs = [
|
||||
...new Set(
|
||||
result.docs
|
||||
.map((update) => relationshipId((update as MunicipalityUpdate).author))
|
||||
.filter((id): id is number => id !== null),
|
||||
),
|
||||
]
|
||||
const authorNameById = await loadCampaignUserNamesByIds(payload, authorIDs)
|
||||
const docs = result.docs as MunicipalityUpdate[]
|
||||
const authorNameById = await loadUpdateAuthorNames(payload, docs)
|
||||
|
||||
return {
|
||||
updates: result.docs.map((update) => {
|
||||
const doc = update as MunicipalityUpdate
|
||||
const polarity = doc.polarity as MunicipalityUpdatePolarity | undefined
|
||||
return {
|
||||
id: doc.id,
|
||||
authorName: authorNameById.get(relationshipId(doc.author) ?? -1) ?? 'Usuário',
|
||||
createdAt: doc.createdAt,
|
||||
body: doc.body ?? null,
|
||||
polarity: polarity && municipalityUpdatePolarities.includes(polarity) ? polarity : 'neutra',
|
||||
urgent: Boolean(doc.urgent),
|
||||
activeVolunteers: doc.activeVolunteers ?? null,
|
||||
newSupports: doc.newSupports ?? null,
|
||||
adversarySignal: Boolean(doc.adversarySignal),
|
||||
}
|
||||
}),
|
||||
updates: docs.map((update) => toMunicipalityUpdateViewModel(update, authorNameById)),
|
||||
totalDocs: result.totalDocs,
|
||||
totalPages: result.totalPages,
|
||||
page: result.page ?? state.page,
|
||||
|
|
@ -140,31 +153,13 @@ export const loadMunicipalityLastUpdates = async (
|
|||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const authorIDs = [
|
||||
...new Set(
|
||||
result.docs
|
||||
.map((update) => relationshipId((update as MunicipalityUpdate).author))
|
||||
.filter((id): id is number => id !== null),
|
||||
),
|
||||
]
|
||||
const authorNameById = await loadCampaignUserNamesByIds(payload, authorIDs)
|
||||
const authorNameById = await loadUpdateAuthorNames(payload, result.docs as MunicipalityUpdate[])
|
||||
|
||||
const lastByMunicipality = new Map<number, MunicipalityUpdateViewModel>()
|
||||
for (const update of result.docs as MunicipalityUpdate[]) {
|
||||
const municipalityID = relationshipId(update.municipality)
|
||||
if (municipalityID === null || lastByMunicipality.has(municipalityID)) continue
|
||||
const polarity = update.polarity as MunicipalityUpdatePolarity | undefined
|
||||
lastByMunicipality.set(municipalityID, {
|
||||
id: update.id,
|
||||
authorName: authorNameById.get(relationshipId(update.author) ?? -1) ?? 'Usuário',
|
||||
createdAt: update.createdAt,
|
||||
body: update.body ?? null,
|
||||
polarity: polarity && municipalityUpdatePolarities.includes(polarity) ? polarity : 'neutra',
|
||||
urgent: Boolean(update.urgent),
|
||||
activeVolunteers: update.activeVolunteers ?? null,
|
||||
newSupports: update.newSupports ?? null,
|
||||
adversarySignal: Boolean(update.adversarySignal),
|
||||
})
|
||||
lastByMunicipality.set(municipalityID, toMunicipalityUpdateViewModel(update, authorNameById))
|
||||
}
|
||||
|
||||
return lastByMunicipality
|
||||
|
|
|
|||
|
|
@ -888,6 +888,9 @@ test.describe('Municípios — filtro e cards sem moldura no celular (B184)', ()
|
|||
|
||||
await campaign.login(page, coordinator.email!, password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios`)
|
||||
// C106 — dynamic pages stream a transient hidden `#S:*` copy of the shell;
|
||||
// strict-mode locators below would match BOTH copies while it settles.
|
||||
await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)
|
||||
|
||||
// The mobile standard hides the field label; the input keeps its name via
|
||||
// aria-label, and the filter form is a borderless sticky bar.
|
||||
|
|
@ -978,7 +981,12 @@ test.describe('Municípios — card denso mobile (B193)', () => {
|
|||
await page.goto(
|
||||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
|
||||
)
|
||||
const card = page.locator('[data-view="mobile-cards"] article').first()
|
||||
// `?q=` matches name PREFIXES ("São Félix" also returns "São Félix do
|
||||
// Coribe"), so the card is anchored on the municipality's own name.
|
||||
const card = page
|
||||
.locator('[data-view="mobile-cards"] article')
|
||||
.filter({ hasText: municipality.name })
|
||||
.first()
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
await card.getByRole('button', { name: /Editar votos estimados/ }).click()
|
||||
|
|
@ -1014,7 +1022,12 @@ test.describe('Municípios — card denso mobile (B193)', () => {
|
|||
await page.goto(
|
||||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
|
||||
)
|
||||
const card = page.locator('[data-view="mobile-cards"] article').first()
|
||||
// `?q=` matches name PREFIXES ("São Félix" also returns "São Félix do
|
||||
// Coribe"), so the card is anchored on the municipality's own name.
|
||||
const card = page
|
||||
.locator('[data-view="mobile-cards"] article')
|
||||
.filter({ hasText: municipality.name })
|
||||
.first()
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
await card.getByText('Nível', { exact: true }).click()
|
||||
|
|
@ -1043,11 +1056,18 @@ test.describe('Municípios — card denso mobile (B193)', () => {
|
|||
await page.goto(
|
||||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
|
||||
)
|
||||
const card = page.locator('[data-view="mobile-cards"] article').first()
|
||||
// `?q=` matches name PREFIXES ("São Félix" also returns "São Félix do
|
||||
// Coribe"), so the card is anchored on the municipality's own name.
|
||||
const card = page
|
||||
.locator('[data-view="mobile-cards"] article')
|
||||
.filter({ hasText: municipality.name })
|
||||
.first()
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
await expect(card).toHaveCSS('border-right-width', '6px')
|
||||
await expect(card).toHaveCSS('border-right-color', 'rgb(197, 20, 20)')
|
||||
// The accent color is a token contract, not a literal: assert the utility
|
||||
// class that paints it (`border-r-primary`) instead of the resolved color.
|
||||
await expect(card).toHaveClass(/border-r-primary/)
|
||||
})
|
||||
|
||||
test('footer expands to the last update card and offers register; empty footer is a direct CTA', async ({
|
||||
|
|
@ -1074,7 +1094,11 @@ test.describe('Municípios — card denso mobile (B193)', () => {
|
|||
await page.goto(
|
||||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(withUpdate.name)}`,
|
||||
)
|
||||
const card = page.locator('[data-view="mobile-cards"] article').first()
|
||||
// `?q=` matches name prefixes — anchor on the municipality's own name.
|
||||
const card = page
|
||||
.locator('[data-view="mobile-cards"] article')
|
||||
.filter({ hasText: withUpdate.name })
|
||||
.first()
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
// The footer button expands the card bottom and reveals the last update
|
||||
|
|
@ -1098,7 +1122,10 @@ test.describe('Municípios — card denso mobile (B193)', () => {
|
|||
// IS the register CTA, without chevron or expansion.
|
||||
const empty = await fixtures.claimMunicipality()
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(empty.name)}`)
|
||||
const emptyCard = page.locator('[data-view="mobile-cards"] article').first()
|
||||
const emptyCard = page
|
||||
.locator('[data-view="mobile-cards"] article')
|
||||
.filter({ hasText: empty.name })
|
||||
.first()
|
||||
await expect(emptyCard).toBeVisible()
|
||||
await expect(emptyCard.getByRole('button', { name: /Última atualização/ })).toHaveCount(0)
|
||||
const emptyCta = emptyCard.getByRole('button', { name: /Registrar atualização em/ })
|
||||
|
|
|
|||
|
|
@ -89,7 +89,9 @@ const overlayCases: OverlayCase[] = [
|
|||
},
|
||||
{
|
||||
name: 'votos estimados',
|
||||
triggerLabel: `Editar votos estimados em ${MUNICIPALITY_NAME} — Média: 1.200`,
|
||||
// B193 — the votes trigger announces the whole interval (the strip shows
|
||||
// all three scenarios), not just the active one.
|
||||
triggerLabel: `Editar votos estimados em ${MUNICIPALITY_NAME} — Pessimista: sem estimativa; Média: 1.200; Otimista: sem estimativa`,
|
||||
drawerTitle: 'Editar votos estimados',
|
||||
element: (variant) =>
|
||||
createElement(MunicipalityListExpectedVotesControl, {
|
||||
|
|
|
|||
|
|
@ -599,6 +599,10 @@ describe('campaign visual foundation', () => {
|
|||
// 40 days old, so the footer reads "há 40 dias" and paints cold — and the
|
||||
// expansion stays collapsed (the update body is not in the DOM).
|
||||
expect(html).toContain('Última atualização há 40 dias')
|
||||
// The cold class is applied ONLY on the mobile footer (the desktop column
|
||||
// uses `data-signal="cold"` on the readout's own span), so this pins the
|
||||
// footer's cold paint without relying on the desktop tree.
|
||||
expect(html).toContain('text-estimate-pending-foreground')
|
||||
expect(html).not.toContain('Visita ao comitê da região.')
|
||||
expect(html).toContain('data-signal="cold"')
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue