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:
parent
322794b1ee
commit
6a20e98047
21 changed files with 712 additions and 375 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
64
scripts/run-e2e-affected.mjs
Normal file
64
scripts/run-e2e-affected.mjs
Normal 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' } : {}),
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
163
src/components/campaign/shared/CampaignListSheetHost.tsx
Normal file
163
src/components/campaign/shared/CampaignListSheetHost.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue