fix(campanha): share one Drawer across municipalities mobile cards

Root cause of the prod-build React #130 on /campanha/municipios: the
md:hidden mobile-cards tree still hydrated ~25 rows x ~5 sheet controls
(~125 eager Drawer/DrawerCloseButton instances) even on wide viewports.
Filtered ?q=... routes failed more often only because they exercise the
list with filters active — the search param itself was not the bug.

CampaignListSheetProvider hosts a single bottom Drawer for the whole
mobile section; CampaignCellEditOverlay sheet cells portal their
body/footer into it while keeping per-cell open state and autosave
scaffolds. Footers with DrawerCloseButton are only created once a sheet
actually opens. Desktop popover path is unchanged; per-cell Drawer
remains the fallback for surfaces without the provider.

Also: DrawerCloseButton (function render prop, avoids base-ui element
render in prod bundles) replaces the DrawerClose render={<Button/>}
pattern across call sites; stale e2e selectors refreshed; e2e affected
script + playwright github/list reporters for CI; use-mobile reverted
(no consumers of the gate remain).

Guardrail filed as issue #52.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-30 16:40:56 -03:00
parent 322794b1ee
commit 6a20e98047
21 changed files with 712 additions and 375 deletions

View file

@ -60,7 +60,7 @@ Labels: estado `ready|in-progress|blocked|done|in-prod`, `prio:P0..P3`, `kind:fe
| `ci-stage.yml` | push em `stage` | `STAGE_DATABASE_URL` (Environment `stage`) | **só** `migrate` + smoke int (subset curado — a suíte completa é lenta demais contra Neon remoto, estourou timeout 2× em 2026-07-30; a full fica no ci-pr/ci.yml) — **NUNCA `pnpm build`** (build migra o banco que vê), nunca seed:minimal |
| `ci.yml` | push em `main` | service Postgres | **gate completo sempre** (rede de segurança pós-promote): estático + unit + int full (com seed:minimal) + build + e2e full |
Fast gate local do agente antes do push: `pnpm gate:fast` (lint + typecheck + test:unit); o hook `.husky/pre-push` roda `pnpm gate:push` (fast + format:check + check:cycles). Fonte única: scripts `gate:*` em `package.json`. knip fica fora do hook (grafo incompleto — ledger P3; CI o roda bloqueante). Escape para WIP: `git push --no-verify` (o CI repete tudo). int/build/e2e = CI.
Fast gate local do agente antes do push: `pnpm gate:fast` (lint + typecheck + test:unit); o hook `.husky/pre-push` roda `pnpm gate:push` (fast + format:check + check:cycles). Fonte única: scripts `gate:*` em `package.json`. knip fica fora do hook (grafo incompleto — ledger P3; CI o roda bloqueante). Escape para WIP: `git push --no-verify` (o CI repete tudo). **E2e afetado localmente (mesmo critério do PR):** `pnpm test:e2e:affected`. int/build/e2e = CI.
## Stage DB — runbook de refresh (semanal, humano)

View file

@ -8,7 +8,9 @@ Three layers, all local-database-only (see `local-database` skill; tests refuse
- **Integration** (`tests/int/**/*.int.spec.ts`, 48 specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned.
- **E2E** (`tests/e2e`, Playwright, 7 specs): smoke-level; most campaign behavior is pinned at the int layer.
CI (`.github/workflows/ci-pr.yml`) runs parallel jobs — lint (zero warnings), format, typecheck, knip, cycles, unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths), e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking) and build — against a Postgres 17 service with **`db:seed:minimal`**. Push to `main` (`ci.yml`) always runs the full net including e2e.
CI (`.github/workflows/ci-pr.yml`) runs parallel jobs — lint (zero warnings), format, typecheck, knip, cycles, unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths), e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; production build + `list`/`github` reporters in CI) and build — against a Postgres 17 service with **`db:seed:minimal`**. Push to `main` (`ci.yml`) always runs the full net including e2e.
**Local e2e before push (same blast radius as PR CI):** `pnpm test:e2e:affected` — runs `e2e-affected.mjs` against `origin/stage`, migrates/seeds, then `pnpm test:e2e` with the selected specs (or the full suite on high-risk diffs). Forward extra Playwright args after `--` for a narrowed repro. Opt into production mode (after fixing the known municipalities-list prod client bug) with `E2E_PROD=1 pnpm test:e2e:affected`.
## Minimal test database (`pnpm db:seed:minimal`)

View file

@ -63,6 +63,7 @@
"test": "pnpm run test:unit && pnpm run test:int",
"test:all": "pnpm run test && pnpm run test:e2e",
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server --import=tsx/esm\" playwright test --config=playwright.config.ts",
"test:e2e:affected": "node scripts/run-e2e-affected.mjs",
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts",
"test:unit": "cross-env NODE_OPTIONS=--no-deprecation DATABASE_URL=postgresql://invalid:invalid@127.0.0.1:1/unit_tests_must_not_connect vitest run --config ./vitest.unit.config.mts",
"typecheck": "tsc --noEmit",

View file

@ -44,7 +44,7 @@ export default defineConfig({
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
reporter: process.env.CI ? [['list'], ['github'], ['html', { open: 'never' }]] : 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
@ -91,13 +91,15 @@ export default defineConfig({
],
webServer: {
/*
* CI serves the PRODUCTION build (`pnpm start` after a `pnpm build` step in
* the e2e job): dev-mode cold webpack compiles per route plus dev-server
* memory restarts made the full suite blow the 30-min job timeout on CI
* (measured 2026-07-30 — first full e2e run on the parallel pipeline).
* Locally `pnpm dev` stays: no build wait and hot reload while writing specs.
* CI and E2E_PROD=1 serve the PRODUCTION build (`pnpm start` after a
* `pnpm build` step in the e2e job): dev-mode cold webpack compiles per
* route plus dev-server memory restarts made the full suite blow the 30-min
* job timeout on CI (measured 2026-07-30). The municipalities-list React
* #130 was fixed by sharing one Drawer across the mobile cards
* (CampaignListSheetProvider, 2026-07-30). Locally `pnpm dev` stays the
* default: no build wait and hot reload while writing specs.
*/
command: process.env.CI ? 'pnpm start' : 'pnpm dev',
command: process.env.CI || process.env.E2E_PROD === '1' ? 'pnpm start' : 'pnpm dev',
/*
* Never reuse a dev server a developer may already have running against the
* production database — always start a fresh one bound to the test DB.

View file

@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* run-e2e-affected — local mirror of the CI (PR) e2e job:
* 1. `e2e-affected.mjs` picks full vs manifest-selected vs skip (same base ref).
* 2. migrate + db:seed:minimal when anything would run.
* 3. optional production build into `.next/e2e` when `E2E_PROD=1`.
* 4. `pnpm test:e2e` (set `CI=1` and `E2E_PROD=1` together for prod mode).
*
* Extra Playwright args after `--` are forwarded (e.g. a single spec while debugging).
*
* GITHUB_BASE_REF=stage node scripts/run-e2e-affected.mjs
* pnpm test:e2e:affected -- tests/e2e/campaignHomeActions.e2e.spec.ts
*/
import { execFileSync, spawnSync } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const passthroughIndex = process.argv.indexOf('--')
const passthroughArgs = passthroughIndex === -1 ? [] : process.argv.slice(passthroughIndex + 1)
const scopeRaw = execFileSync('node', ['scripts/e2e-affected.mjs'], {
cwd: repoRoot,
encoding: 'utf8',
env: process.env,
})
const scope = JSON.parse(scopeRaw.trim())
console.log(`[e2e:affected] ${scope.reason} (base ${scope.base})`)
if (scope.mode === 'none') {
console.log('[e2e:affected] Nothing to run for this diff.')
process.exit(0)
}
const run = (command, args, env = {}) => {
const result = spawnSync(command, args, {
cwd: repoRoot,
stdio: 'inherit',
env: { ...process.env, ...env },
})
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
run('pnpm', ['migrate'])
run('pnpm', ['db:seed:minimal'])
if (process.env.E2E_PROD === '1') {
run('pnpm', ['build'], { NEXT_DIST_DIR: '.next/e2e' })
}
const playwrightArgs = ['test:e2e']
if (scope.mode === 'selected' && scope.specs.length > 0) {
for (const name of scope.specs) {
playwrightArgs.push(`tests/e2e/${name}.e2e.spec.ts`)
}
}
playwrightArgs.push(...passthroughArgs)
run('pnpm', playwrightArgs, {
...(process.env.E2E_PROD === '1' ? { CI: '1', E2E_PROD: '1' } : {}),
})

View file

@ -4,10 +4,9 @@ import type { LucideIcon } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useId, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerClose,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,
@ -149,11 +148,7 @@ export const CampaignHomeActionButton = ({
<DrawerDescription id={descriptionId}>{description}</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
>
Fechar
</DrawerClose>
<DrawerCloseButton>Fechar</DrawerCloseButton>
</DrawerFooter>
</DrawerContent>
</Drawer>

View file

@ -1,10 +1,9 @@
import { CircleAlertIcon, SearchXIcon } from 'lucide-react'
import { SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import {
advisorEntriesFromIds,
formatAdvisorNamesTooltip,
MissingAdvisorBadge,
MunicipalityAdvisorAvatarStack,
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
@ -12,16 +11,19 @@ import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipal
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/municipality/MunicipalityListGoalCoverageCell'
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
import { MunicipalityListMobileSection } from '@/components/campaign/municipality/MunicipalityListMobileSection'
import {
SignalAgeReadout,
TerritorialClassReadout,
} from '@/components/campaign/municipality/MunicipalityListRowReadouts'
import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import { MunicipalityPriorityIndicator } from '@/components/campaign/municipality/MunicipalityPriorityIndicator'
import { MunicipalitySortableHead } from '@/components/campaign/municipality/MunicipalitySortableHead'
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
import { TerritoryLink } from '@/components/campaign/municipality/TerritoryLink'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
import { Badge } from '@/components/ui/Badge'
import { buttonVariants } from '@/components/ui/button'
import {
Empty,
@ -41,9 +43,6 @@ import type { CampaignFormActionState } from '@/utilities/campaignFormActionErro
import {
formatTerritorialClassWhy,
municipalityColumnDescriptions,
municipalityGeographyParts,
territorialClassBadgeVariant,
territorialClassLabels,
type MunicipalityListColumnId,
} from '@/utilities/municipality/municipalityLabels'
import {
@ -57,12 +56,6 @@ import {
resolveMunicipalityListSort,
type MunicipalityListState,
} from '@/utilities/municipality/municipalityListUrl'
import {
formatMunicipalitySignalAgeLabel,
isMunicipalitySignalCold,
MUNICIPALITY_COLD_SIGNAL_DAYS,
municipalitySignalAgeInDays,
} from '@/utilities/municipality/municipalitySignal'
import type {
EligibleAdvisorOption,
MunicipalityAdvisorSummary,
@ -102,87 +95,6 @@ export type MunicipalityListProps = {
columnVisibility: CampaignColumnVisibility
}
/**
* E10 classe. Only the pill is visible — spelling the factors out under it
* bloats the dense row on desktop and, on the mobile card, was the tallest
* block in a card the field asked us to shorten (B42). So the "por quê" is
* `sr-only` text here, and both surfaces add a tap/hover/focus channel around
* it: the column's `cellTooltip` in the table, an explicit `CampaignHoverTooltip`
* on the card. Neither may be dropped — the class must never reach anyone,
* sighted or not, as a bare verdict.
*/
const TerritorialClassReadout = ({ municipality }: { municipality: MunicipalityListViewModel }) => {
const why = formatTerritorialClassWhy(municipality.territorialClassFactors)
if (municipality.territorialClass === 'sem_base') {
// Same idiom as the "2022" column: absent data is a dash, not a pill.
return (
<span className="text-muted-foreground">
<span aria-hidden="true">—</span>
<span className="sr-only">{why}</span>
</span>
)
}
return (
<>
<Badge variant={territorialClassBadgeVariant[municipality.territorialClass]}>
{territorialClassLabels[municipality.territorialClass]}
</Badge>
<span className="sr-only">{why}</span>
</>
)
}
/**
* E9 frescor: how long since anybody recorded anything here (staff update or
* leadership pledge).
*
* Cold reads in the warning amber, NOT destructive: early in the campaign
* most municípios are past the 21-day threshold, and painting them all red
* would drown the one state that really is an error in this list — a priority
* município with nobody answering for it. Same reason the priority flag stays
* an icon instead of a third badge: the row already carries the priority signal and,
* when it applies, the missing-advisor one.
*/
const SignalAgeReadout = ({
lastSignalAt,
layout,
}: {
lastSignalAt: string | null
layout: 'table' | 'card'
}) => {
const ageInDays = municipalitySignalAgeInDays(lastSignalAt)
const isCold = isMunicipalitySignalCold(ageInDays)
const ageLabel = formatMunicipalitySignalAgeLabel(ageInDays)
const dateLabel = lastSignalAt ? dateFormatter.format(new Date(lastSignalAt)) : null
return (
<div
data-signal={isCold ? 'cold' : 'fresh'}
className={cn('flex flex-col gap-0.5', layout === 'card' && 'text-sm')}
title={
isCold
? `Sem registro novo há ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias ou mais — atualização da equipe ou declaração de liderança.`
: undefined
}
>
<span
className={cn(
'inline-flex items-center gap-1 tabular-nums',
isCold ? 'font-medium text-estimate-pending-foreground' : 'text-foreground',
)}
>
{isCold ? <CircleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" /> : null}
{ageLabel}
</span>
{dateLabel ? (
<span className="text-xs text-muted-foreground tabular-nums">{dateLabel}</span>
) : null}
</div>
)
}
/** Replaces only the rows: the filter header row and the overview stay put. */
const MunicipalityListEmptyState = ({ state }: { state: MunicipalityListState }) => (
<Empty className="min-h-56">
@ -214,11 +126,6 @@ const advisorEntries = (
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
) => advisorEntriesFromIds(municipality.advisorIDs, advisorNamesById)
const advisorNames = (
municipality: MunicipalityListViewModel,
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
): string[] => advisorEntries(municipality, advisorNamesById).map((advisor) => advisor.name)
/**
* Desktop table columns (Pass 2 W1). `id` is the picker key, cookie key, and B22
* description key — typed instead of widening to `string`.
@ -524,172 +431,16 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
{sortSummary}
</p>
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
{municipalities.length === 0 ? <MunicipalityListEmptyState state={state} /> : null}
{municipalities.map((municipality) => {
const names = advisorNames(municipality, advisorNamesById)
const position = municipality.votePosition2022
const isPriority = municipality.priority === 'alta'
const { region, zoneSuffix } = municipalityGeographyParts(municipality)
return (
<article
key={municipality.id}
className="relative flex flex-col gap-3 rounded-xl border p-4"
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
{/*
* The name's link stretches over the whole card, so tapping
* anywhere that isn't a control opens the município — B42
* removed the "Abrir município" button that used to be the
* only way in. Each control carries its own `relative`,
* which paints it above this overlay and keeps its own tap;
* nothing else in the card may be positioned, or it would
* become a hole in the card's tap target.
*/}
<h3 className="font-medium">
<Link
href={`/campanha/municipios/${municipality.slug}`}
className="rounded-md after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{municipality.name}
</Link>
</h3>
<p className="text-sm text-muted-foreground">
<span className="relative">
<TerritoryLink region={region} />
</span>
{zoneSuffix ? ` ${zoneSuffix}` : null}
</p>
{position ? (
<MunicipalityVotePositionReadout position={position} layout="card" />
) : null}
</div>
{isPriority && isStaffView ? (
<MunicipalityPriorityIndicator className="relative size-11" />
) : null}
</div>
{isStaffView ? (
<dl className="grid grid-cols-2 gap-2 text-sm">
<div>
<dt className="text-muted-foreground">Classe</dt>
<dd className="flex flex-wrap items-center gap-2">
{/*
* The card has no hover, so the "por quê" the class must
* never travel without gets the same tap-to-open channel
* the desktop column gets through `cellTooltip` — the
* `sr-only` copy inside the readout covers AT either way.
*/}
<CampaignHoverTooltip
content={
municipality.territorialClass === 'sem_base'
? null
: formatTerritorialClassWhy(municipality.territorialClassFactors)
}
align="start"
>
<span className="relative inline-flex items-center">
<TerritorialClassReadout municipality={municipality} />
</span>
</CampaignHoverTooltip>
</dd>
</div>
<div>
<dt className="text-muted-foreground">
{municipalityColumnLabels.goalCoverage}
</dt>
<dd>
<MunicipalityListGoalCoverageCell
coverageByScenario={municipality.goalCoverageByScenario}
/>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Votos estimados</dt>
<dd>
<MunicipalityListExpectedVotesControl
municipalityID={municipality.id}
municipalityName={municipality.name}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
variant="sheet"
/>
</dd>
</div>
<div className="col-span-2">
<dt className="text-muted-foreground">Nível</dt>
<dd>
{props.canMoveEngagementLevel ? (
<MunicipalityListLevelControl
municipalityID={municipality.id}
municipalityName={municipality.name}
level={municipality.engagementLevel}
levelNote={municipality.levelNote}
levelChangedAt={municipality.levelChangedAt}
variant="sheet"
/>
) : (
<MunicipalityLevelBadge
level={municipality.engagementLevel}
note={municipality.levelNote}
layout="card"
/>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Tendência</dt>
<dd>
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalityName={municipality.name}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
variant="sheet"
/>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Último sinal</dt>
<dd>
<MunicipalityListSignalControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
municipalityName={municipality.name}
lastSignalAt={municipality.lastSignalAt}
variant="sheet"
formAction={props.signalFormAction}
>
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="card" />
</MunicipalityListSignalControl>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Assessores</dt>
<dd>
{isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentAdvisorIDs={municipality.advisorIDs}
isPriority={isPriority}
advisorNamesById={advisorNamesById}
options={advisorOptions}
variant="sheet"
/>
) : names.length ? (
names.join(', ')
) : (
<MissingAdvisorBadge isPriority={isPriority} />
)}
</dd>
</div>
</dl>
) : null}
</article>
)
})}
</div>
<MunicipalityListMobileSection
municipalities={municipalities}
advisorNamesById={advisorNamesById}
isStaffView={isStaffView}
isCoordinator={isCoordinator}
canMoveEngagementLevel={props.canMoveEngagementLevel}
advisorOptions={advisorOptions}
signalFormAction={props.signalFormAction}
emptySlot={<MunicipalityListEmptyState state={state} />}
/>
{/* A sticky <th> can't paint the row border, hence the inset shadow. */}
<CampaignTable

View file

@ -12,7 +12,7 @@ import { useCampaignCellFailureChannel } from '@/components/campaign/shared/useC
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { DrawerClose } from '@/components/ui/Drawer'
import { DrawerCloseButton } from '@/components/ui/Drawer'
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
@ -236,14 +236,10 @@ export const MunicipalityListLevelControl = ({
triggerBusy={isPending}
statusMessage={isPending ? 'Registrando nível.' : (errorMessage ?? '')}
footer={
isSheet ? (
isSheet && open ? (
<>
{submitButton}
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
>
Cancelar
</DrawerClose>
<DrawerCloseButton>Cancelar</DrawerCloseButton>
</>
) : undefined
}

View file

@ -0,0 +1,204 @@
'use client'
import Link from 'next/link'
import type { ReactNode } from 'react'
import {
advisorEntriesFromIds,
MissingAdvisorBadge,
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/municipality/MunicipalityListGoalCoverageCell'
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
import {
SignalAgeReadout,
TerritorialClassCardReadout,
} from '@/components/campaign/municipality/MunicipalityListRowReadouts'
import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import { MunicipalityPriorityIndicator } from '@/components/campaign/municipality/MunicipalityPriorityIndicator'
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
import { TerritoryLink } from '@/components/campaign/municipality/TerritoryLink'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { municipalityGeographyParts } from '@/utilities/municipality/municipalityLabels'
import { municipalityColumnLabels } from '@/utilities/municipality/municipalityListUrl'
import type {
EligibleAdvisorOption,
MunicipalityAdvisorSummary,
MunicipalityListViewModel,
} from '@/utilities/municipality/municipalityViewModels'
import { toMunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
type MunicipalityStaffFormAction = (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
export type MunicipalityListMobileCardsProps = {
municipalities: MunicipalityListViewModel[]
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
isStaffView: boolean
isCoordinator: boolean
canMoveEngagementLevel: boolean
advisorOptions: EligibleAdvisorOption[]
signalFormAction: MunicipalityStaffFormAction
emptySlot: ReactNode
}
/**
* B42 mobile cards with sheet-variant quick edits. Wrapped by
* `MunicipalityListMobileSection`, which hosts a single shared Drawer.
*/
export const MunicipalityListMobileCards = ({
municipalities,
advisorNamesById,
isStaffView,
isCoordinator,
canMoveEngagementLevel,
advisorOptions,
signalFormAction,
emptySlot,
}: MunicipalityListMobileCardsProps) => (
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
{municipalities.length === 0 ? emptySlot : null}
{municipalities.map((municipality) => {
const names = advisorEntriesFromIds(municipality.advisorIDs, advisorNamesById).map(
(advisor) => advisor.name,
)
const position = municipality.votePosition2022
const isPriority = municipality.priority === 'alta'
const { region, zoneSuffix } = municipalityGeographyParts(municipality)
return (
<article
key={municipality.id}
className="relative flex flex-col gap-3 rounded-xl border p-4"
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<h3 className="font-medium">
<Link
href={`/campanha/municipios/${municipality.slug}`}
className="rounded-md after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{municipality.name}
</Link>
</h3>
<p className="text-sm text-muted-foreground">
<span className="relative">
<TerritoryLink region={region} />
</span>
{zoneSuffix ? ` ${zoneSuffix}` : null}
</p>
{position ? (
<MunicipalityVotePositionReadout position={position} layout="card" />
) : null}
</div>
{isPriority && isStaffView ? (
<MunicipalityPriorityIndicator className="relative size-11" />
) : null}
</div>
{isStaffView ? (
<dl className="grid grid-cols-2 gap-2 text-sm">
<div>
<dt className="text-muted-foreground">Classe</dt>
<dd className="flex flex-wrap items-center gap-2">
<TerritorialClassCardReadout municipality={municipality} />
</dd>
</div>
<div>
<dt className="text-muted-foreground">{municipalityColumnLabels.goalCoverage}</dt>
<dd>
<MunicipalityListGoalCoverageCell
coverageByScenario={municipality.goalCoverageByScenario}
/>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Votos estimados</dt>
<dd>
<MunicipalityListExpectedVotesControl
municipalityID={municipality.id}
municipalityName={municipality.name}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
variant="sheet"
/>
</dd>
</div>
<div className="col-span-2">
<dt className="text-muted-foreground">Nível</dt>
<dd>
{canMoveEngagementLevel ? (
<MunicipalityListLevelControl
municipalityID={municipality.id}
municipalityName={municipality.name}
level={municipality.engagementLevel}
levelNote={municipality.levelNote}
levelChangedAt={municipality.levelChangedAt}
variant="sheet"
/>
) : (
<MunicipalityLevelBadge
level={municipality.engagementLevel}
note={municipality.levelNote}
layout="card"
/>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Tendência</dt>
<dd>
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalityName={municipality.name}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
variant="sheet"
/>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Último sinal</dt>
<dd>
<MunicipalityListSignalControl
municipalityID={municipality.id}
municipalitySlug={municipality.slug}
municipalityName={municipality.name}
lastSignalAt={municipality.lastSignalAt}
variant="sheet"
formAction={signalFormAction}
>
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="card" />
</MunicipalityListSignalControl>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Assessores</dt>
<dd>
{isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentAdvisorIDs={municipality.advisorIDs}
isPriority={isPriority}
advisorNamesById={advisorNamesById}
options={advisorOptions}
variant="sheet"
/>
) : names.length ? (
names.join(', ')
) : (
<MissingAdvisorBadge isPriority={isPriority} />
)}
</dd>
</div>
</dl>
) : null}
</article>
)
})}
</div>
)

View file

@ -0,0 +1,14 @@
'use client'
import type { MunicipalityListMobileCardsProps } from '@/components/campaign/municipality/MunicipalityListMobileCards'
import { MunicipalityListMobileCards } from '@/components/campaign/municipality/MunicipalityListMobileCards'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
type MunicipalityListMobileSectionProps = MunicipalityListMobileCardsProps
/** Mobile cards (`md:hidden`) with one shared bottom sheet for all quick edits. */
export const MunicipalityListMobileSection = (props: MunicipalityListMobileSectionProps) => (
<CampaignListSheetProvider>
<MunicipalityListMobileCards {...props} />
</CampaignListSheetProvider>
)

View file

@ -0,0 +1,106 @@
'use client'
import { CircleAlertIcon } from 'lucide-react'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { Badge } from '@/components/ui/Badge'
import { cn } from '@/lib/utils'
import {
formatTerritorialClassWhy,
territorialClassBadgeVariant,
territorialClassLabels,
} from '@/utilities/municipality/municipalityLabels'
import {
formatMunicipalitySignalAgeLabel,
isMunicipalitySignalCold,
MUNICIPALITY_COLD_SIGNAL_DAYS,
municipalitySignalAgeInDays,
} from '@/utilities/municipality/municipalitySignal'
import type { MunicipalityListViewModel } from '@/utilities/municipality/municipalityViewModels'
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
/** E10 classe readout — shared by the table column and the mobile card (B42). */
export const TerritorialClassReadout = ({
municipality,
}: {
municipality: MunicipalityListViewModel
}) => {
const why = formatTerritorialClassWhy(municipality.territorialClassFactors)
if (municipality.territorialClass === 'sem_base') {
return (
<span className="text-muted-foreground">
<span aria-hidden="true">—</span>
<span className="sr-only">{why}</span>
</span>
)
}
return (
<>
<Badge variant={territorialClassBadgeVariant[municipality.territorialClass]}>
{territorialClassLabels[municipality.territorialClass]}
</Badge>
<span className="sr-only">{why}</span>
</>
)
}
/** E9 frescor readout — shared by the table column and the mobile card. */
export const SignalAgeReadout = ({
lastSignalAt,
layout,
}: {
lastSignalAt: string | null
layout: 'table' | 'card'
}) => {
const ageInDays = municipalitySignalAgeInDays(lastSignalAt)
const isCold = isMunicipalitySignalCold(ageInDays)
const ageLabel = formatMunicipalitySignalAgeLabel(ageInDays)
const dateLabel = lastSignalAt ? dateFormatter.format(new Date(lastSignalAt)) : null
return (
<div
data-signal={isCold ? 'cold' : 'fresh'}
className={cn('flex flex-col gap-0.5', layout === 'card' && 'text-sm')}
title={
isCold
? `Sem registro novo há ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias ou mais — atualização da equipe ou declaração de liderança.`
: undefined
}
>
<span
className={cn(
'inline-flex items-center gap-1 tabular-nums',
isCold ? 'font-medium text-estimate-pending-foreground' : 'text-foreground',
)}
>
{isCold ? <CircleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" /> : null}
{ageLabel}
</span>
{dateLabel ? (
<span className="text-xs text-muted-foreground tabular-nums">{dateLabel}</span>
) : null}
</div>
)
}
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>
)

View file

@ -10,7 +10,7 @@ import {
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
import { Button } from '@/components/ui/button'
import { DrawerClose } from '@/components/ui/Drawer'
import { DrawerCloseButton } from '@/components/ui/Drawer'
import { Spinner } from '@/components/ui/Spinner'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
@ -82,14 +82,10 @@ export const MunicipalityListSignalControl = ({
contentClassName="w-80"
trigger={children}
footer={
isSheet ? (
isSheet && open ? (
<>
{submitButton}
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
>
Cancelar
</DrawerClose>
<DrawerCloseButton>Cancelar</DrawerCloseButton>
</>
) : undefined
}

View file

@ -9,7 +9,7 @@ import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizard
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerClose,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,
@ -104,9 +104,7 @@ export const WizardSignalTypeStep = ({
<p className="text-sm text-muted-foreground">{infoEntry.infoContent}</p>
</div>
<DrawerFooter>
<DrawerClose render={<Button variant="outline" className="min-h-11 w-full" />}>
Fechar
</DrawerClose>
<DrawerCloseButton>Fechar</DrawerCloseButton>
</DrawerFooter>
</>
) : null}

View file

@ -1,12 +1,13 @@
'use client'
import { useRef, useState, type ReactNode } from 'react'
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { Button } from '@/components/ui/button'
import { useCampaignListSheet } from '@/components/campaign/shared/CampaignListSheetHost'
import {
Drawer,
DrawerClose,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,
@ -82,6 +83,10 @@ type CampaignCellEditOverlayProps = {
* Only the container lives here. Each control keeps its own auto-save scaffold
* (debounce, abort, flush on close), which is what `onOpenChange` carries — so
* closing the Drawer commits exactly like dismissing the Popover does.
*
* When a `CampaignListSheetProvider` wraps the list (B42 mobile cards), sheet
* cells publish into that single Drawer instead of mounting one per row — prod
* was tripping React #130 with ~125 idle drawer roots on a 25-row page.
*/
export const CampaignCellEditOverlay = ({
variant,
@ -102,6 +107,9 @@ export const CampaignCellEditOverlay = ({
preventPopoverAutoFocus,
children,
}: CampaignCellEditOverlayProps) => {
const sharedSheet = useCampaignListSheet()
const wasOpenRef = useRef(open)
/**
* The region is mounted from the first open onwards and never unmounted
* again — a live region has to exist BEFORE its text changes or most screen
@ -152,50 +160,73 @@ export const CampaignCellEditOverlay = ({
</p>
)
useLayoutEffect(() => {
if (variant !== 'sheet' || !sharedSheet) return
const wasOpen = wasOpenRef.current
wasOpenRef.current = open
if (!open) {
if (wasOpen) sharedSheet.dismissSheet(onOpenChange)
return
}
sharedSheet.openSheet({
title,
description,
hasCustomFooter: Boolean(footer),
sheetBodyClassName,
onOpenChange,
})
}, [variant, sharedSheet, open, title, description, footer, sheetBodyClassName, onOpenChange])
if (variant === 'sheet' && sharedSheet) {
const showPortals = open && sharedSheet.isActiveSheet(onOpenChange)
const bodyTarget = showPortals ? sharedSheet.bodyPortalRef.current : null
const footerTarget = showPortals ? sharedSheet.footerPortalRef.current : null
// portalRevision keeps this render in sync after the drawer mounts its targets.
void sharedSheet.portalRevision
return (
<>
{triggerButton}
{liveRegion}
{showPortals && bodyTarget ? createPortal(children, bodyTarget) : null}
{showPortals && footerTarget && footer ? createPortal(footer, footerTarget) : null}
</>
)
}
if (variant === 'sheet') {
return (
<>
{triggerButton}
{liveRegion}
<Drawer open={open} onOpenChange={onOpenChange}>
{/*
* Focus lands on the title, never on the first field: base-ui's
* default (first tabbable element) raises the virtual keyboard over
* the sheet before it can be read, and every one of these sheets is
* opened to look at a value at least as often as to type one. The
* title is focusable only programmatically (`tabIndex={-1}`), so it
* adds no stop to the tab order.
*/}
<DrawerContent initialFocus={titleRef}>
<DrawerHeader>
<DrawerTitle ref={titleRef} tabIndex={-1} className="outline-none">
{title}
</DrawerTitle>
{/* Guarded, not required: every sheet today names its row, but one
opened from under a heading that already does would pass none. */}
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<div
className={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-3 pb-2',
sheetBodyClassName,
)}
>
{children}
</div>
<DrawerFooter>
{footer ?? (
// Swiping down is the gesture, but it is not an affordance
// anyone sees.
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
{open || everOpened ? (
<Drawer open={open} onOpenChange={onOpenChange}>
{open ? (
<DrawerContent initialFocus={titleRef}>
<DrawerHeader>
<DrawerTitle ref={titleRef} tabIndex={-1} className="outline-none">
{title}
</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<div
className={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-3 pb-2',
sheetBodyClassName,
)}
>
Fechar
</DrawerClose>
)}
</DrawerFooter>
</DrawerContent>
</Drawer>
{children}
</div>
<DrawerFooter>
{footer ?? <DrawerCloseButton>Fechar</DrawerCloseButton>}
</DrawerFooter>
</DrawerContent>
) : null}
</Drawer>
) : null}
</>
)
}

View file

@ -0,0 +1,163 @@
'use client'
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
type RefObject,
} from 'react'
import {
Drawer,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import { cn } from '@/lib/utils'
export type CampaignListSheetChrome = {
title: string
description?: string
hasCustomFooter: boolean
sheetBodyClassName?: string
onOpenChange: (open: boolean) => void
}
type CampaignListSheetContextValue = {
bodyPortalRef: RefObject<HTMLDivElement | null>
footerPortalRef: RefObject<HTMLDivElement | null>
/** Bumps when the drawer body/footer mount targets attach — portals read this. */
portalRevision: number
openSheet: (chrome: CampaignListSheetChrome) => void
dismissSheet: (onOpenChange: (open: boolean) => void) => void
isActiveSheet: (onOpenChange: (open: boolean) => void) => boolean
}
const CampaignListSheetContext = createContext<CampaignListSheetContextValue | null>(null)
export const useCampaignListSheet = () => useContext(CampaignListSheetContext)
/**
* One bottom Drawer for an entire list surface (B42 mobile cards). Cell bodies
* portal into it so we never mount hundreds of Drawer roots and never sync form
* trees through provider state (which tripped React #185 in prod).
*/
export const CampaignListSheetProvider = ({ children }: { children: ReactNode }) => {
const titleRef = useRef<HTMLHeadingElement | null>(null)
const bodyPortalRef = useRef<HTMLDivElement | null>(null)
const footerPortalRef = useRef<HTMLDivElement | null>(null)
const [open, setOpen] = useState(false)
const [chrome, setChrome] = useState<CampaignListSheetChrome | null>(null)
const [portalRevision, setPortalRevision] = useState(0)
const activeOnOpenChangeRef = useRef<((open: boolean) => void) | null>(null)
const attachBodyPortal = useCallback((node: HTMLDivElement | null) => {
bodyPortalRef.current = node
if (node) setPortalRevision((value) => value + 1)
}, [])
const attachFooterPortal = useCallback((node: HTMLDivElement | null) => {
footerPortalRef.current = node
if (node) setPortalRevision((value) => value + 1)
}, [])
const dismissSheet = useCallback((onOpenChange: (open: boolean) => void) => {
if (activeOnOpenChangeRef.current !== onOpenChange) return
setOpen(false)
activeOnOpenChangeRef.current = null
setChrome(null)
onOpenChange(false)
}, [])
const openSheet = useCallback((next: CampaignListSheetChrome) => {
const previousOnOpenChange = activeOnOpenChangeRef.current
if (previousOnOpenChange && previousOnOpenChange !== next.onOpenChange) {
previousOnOpenChange(false)
}
activeOnOpenChangeRef.current = next.onOpenChange
setChrome((current) => {
if (
current &&
current.onOpenChange === next.onOpenChange &&
current.title === next.title &&
current.description === next.description &&
current.sheetBodyClassName === next.sheetBodyClassName &&
current.hasCustomFooter === next.hasCustomFooter
) {
return current
}
return next
})
setOpen(true)
}, [])
const isActiveSheet = useCallback(
(onOpenChange: (open: boolean) => void) =>
open && activeOnOpenChangeRef.current === onOpenChange,
[open],
)
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) return
const onOpenChange = activeOnOpenChangeRef.current
if (!onOpenChange) {
setOpen(false)
setChrome(null)
return
}
dismissSheet(onOpenChange)
},
[dismissSheet],
)
const contextValue = useMemo(
() => ({
bodyPortalRef,
footerPortalRef,
portalRevision,
openSheet,
dismissSheet,
isActiveSheet,
}),
[portalRevision, openSheet, dismissSheet, isActiveSheet],
)
return (
<CampaignListSheetContext.Provider value={contextValue}>
{children}
<Drawer open={open} onOpenChange={handleOpenChange}>
{chrome && open ? (
<DrawerContent initialFocus={titleRef}>
<DrawerHeader>
<DrawerTitle ref={titleRef} tabIndex={-1} className="outline-none">
{chrome.title}
</DrawerTitle>
{chrome.description ? (
<DrawerDescription>{chrome.description}</DrawerDescription>
) : null}
</DrawerHeader>
<div
ref={attachBodyPortal}
className={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-3 pb-2',
chrome.sheetBodyClassName,
)}
/>
<DrawerFooter>
<div ref={attachFooterPortal} className="flex w-full flex-col gap-2" />
{!chrome.hasCustomFooter ? <DrawerCloseButton>Fechar</DrawerCloseButton> : null}
</DrawerFooter>
</DrawerContent>
) : null}
</Drawer>
</CampaignListSheetContext.Provider>
)
}

View file

@ -3,10 +3,9 @@
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerClose,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,
@ -122,7 +121,7 @@ export const InstallPwaToast = () => {
}
}, [])
return (
return iosDrawerOpen ? (
<Drawer open={iosDrawerOpen} onOpenChange={setIosDrawerOpen}>
<DrawerContent>
<DrawerHeader>
@ -137,20 +136,9 @@ export const InstallPwaToast = () => {
<li>Confirme tocando em Adicionar.</li>
</ol>
<DrawerFooter>
<DrawerClose
render={
<Button
type="button"
variant="outline"
className="w-full"
onClick={dismissInstallToast}
/>
}
>
Entendi
</DrawerClose>
<DrawerCloseButton onClick={dismissInstallToast}>Entendi</DrawerCloseButton>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
) : null
}

View file

@ -1,8 +1,9 @@
'use client'
import { Drawer as DrawerPrimitive } from '@base-ui/react/drawer'
import { Drawer as DrawerPrimitive } from '@base-ui/react'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
type DrawerContextProps = {
@ -62,6 +63,32 @@ const DrawerClose = ({ ...props }: DrawerPrimitive.Close.Props) => (
<DrawerPrimitive.Close data-slot="drawer-close" {...props} />
)
/** Styled close control — uses a render callback so prod bundles never hit base-ui's element `render` prop. */
const DrawerCloseButton = ({
className,
children,
onClick,
...props
}: React.ComponentProps<typeof Button>) => (
<DrawerClose
render={(closeProps) => (
<Button
type="button"
variant="outline"
className={cn('min-h-11 w-full', className)}
{...props}
{...closeProps}
onClick={(event) => {
closeProps.onClick?.(event)
onClick?.(event)
}}
/>
)}
>
{children}
</DrawerClose>
)
const DrawerOverlay = ({ className, ...props }: DrawerPrimitive.Backdrop.Props) => (
<DrawerPrimitive.Backdrop
data-slot="drawer-overlay"
@ -169,6 +196,7 @@ const DrawerDescription = ({ className, ...props }: DrawerPrimitive.Description.
export {
Drawer,
DrawerClose,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,

View file

@ -43,15 +43,14 @@ test.describe('Início — busca global (B47)', () => {
})
await campaign.login(page, coordinator.email!, coordinator.password)
const actionsSlot = page.locator('[data-slot="home-actions"]')
await expect(actionsSlot).toBeVisible()
const actionsChrome = page.locator('[data-slot="home-actions-chrome"]')
await expect(page.getByLabel('Ações rápidas')).toBeVisible()
await page.getByLabel('Buscar na campanha').focus()
await expect(actionsSlot).toBeHidden({ timeout: 5000 })
await expect(actionsChrome).toHaveAttribute('data-retracted', 'true', { timeout: 5000 })
await page.getByLabel('Buscar na campanha').blur()
await expect(actionsSlot).toBeVisible({ timeout: 5000 })
await expect(actionsChrome).not.toHaveAttribute('data-retracted', 'true', { timeout: 5000 })
})
test('staff typing in search keeps action strip hidden after debounce', async ({
@ -64,14 +63,15 @@ test.describe('Início — busca global (B47)', () => {
})
await campaign.login(page, coordinator.email!, coordinator.password)
const actionsSlot = page.locator('[data-slot="home-actions"]')
await expect(actionsSlot).toBeVisible()
const actionsChrome = page.locator('[data-slot="home-actions-chrome"]')
await expect(page.getByLabel('Ações rápidas')).toBeVisible()
await page.getByLabel('Buscar na campanha').fill('ca')
await expect(actionsSlot).toBeHidden({ timeout: 5000 })
await expect(actionsChrome).toHaveAttribute('data-retracted', 'true', { timeout: 5000 })
await page.getByLabel('Buscar na campanha').fill('')
await expect(actionsSlot).toBeVisible({ timeout: 5000 })
await page.getByLabel('Buscar na campanha').blur()
await expect(actionsChrome).not.toHaveAttribute('data-retracted', 'true', { timeout: 5000 })
})
test('staff search shows municipality hits and opens detail (B48)', async ({
@ -148,6 +148,8 @@ test.describe('Início — catálogo de ações (B45)', () => {
})
test.describe('Wizard — busca município (B60)', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('selecting a municipality advances with ?municipio= and sticky caption', async ({
campaign,
page,
@ -165,8 +167,8 @@ test.describe('Wizard — busca município (B60)', () => {
await search.fill('Cairu')
const results = page.getByRole('region', { name: 'Resultados da busca' })
await expect(results.getByRole('button', { name: /Cairu/i })).toBeVisible({ timeout: 15000 })
await results.getByRole('button', { name: /Cairu/i }).click()
await expect(results.getByRole('link', { name: /Cairu/i })).toBeVisible({ timeout: 15000 })
await results.getByRole('link', { name: /Cairu/i }).click()
await page.waitForURL(/\/campanha\/acoes\/atualizar-votos\?municipio=cairu/)
await expect(page.getByLabel(/Município em atualização: Cairu/i)).toBeVisible()
@ -299,12 +301,7 @@ test.describe('Wizard — registrar sinal (B63)', () => {
})
await expect(page.getByRole('link', { name: 'Pular registro de sinal' })).toHaveCount(0)
await page
.getByRole('listitem')
.filter({ hasText: 'Invasão' })
.getByRole('button')
.first()
.click()
await page.getByRole('link', { name: /Invasão/i }).click()
await page.waitForURL(/signalType=invasao/)
await expect(page.getByRole('heading', { name: /Detalhar sinal: Invasão/i })).toBeVisible()

View file

@ -63,6 +63,6 @@ test.describe('Territórios de Identidade', () => {
await expect(page.getByRole('link', { name: 'Territórios', exact: true })).toHaveCount(0)
await page.goto('/campanha/territorios')
await expect(page).toHaveURL(/\/campanha\/?$/)
await expect(page).toHaveURL(/\/campanha\/contatos/)
})
})

View file

@ -39,7 +39,7 @@ test.describe('Wizard — header mobile (B75)', () => {
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/acoes/atualizar-votos?municipio=cairu')
await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible({
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible({
timeout: 15000,
})

View file

@ -15,6 +15,7 @@ test('the dashboard map paints Salvador zone by zone and opens one', async ({ ca
const password = coordinator.password
await campaign.login(page, coordinator.email!, password)
await page.goto('/campanha/quadro')
await expect(page.getByRole('heading', { name: 'Mapa dos Municípios' })).toBeVisible({
timeout: 60_000,