diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 455547aa..13ef5766 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -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) diff --git a/docs/TESTING.md b/docs/TESTING.md index b72a2e3d..6114d009 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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`) diff --git a/package.json b/package.json index 2e9635ad..757dd124 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index 38559d44..91fe8301 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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. diff --git a/scripts/run-e2e-affected.mjs b/scripts/run-e2e-affected.mjs new file mode 100644 index 00000000..1df1c734 --- /dev/null +++ b/scripts/run-e2e-affected.mjs @@ -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' } : {}), +}) diff --git a/src/components/campaign/dashboard/CampaignHomeActionButton.tsx b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx index 0d682e9d..43505bfa 100644 --- a/src/components/campaign/dashboard/CampaignHomeActionButton.tsx +++ b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx @@ -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 = ({ {description} - } - > - Fechar - + Fechar diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index 0c5c6281..e53f2bc5 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -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 ( - - - {why} - - ) - } - - return ( - <> - - {territorialClassLabels[municipality.territorialClass]} - - {why} - - ) -} - -/** - * 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 ( -
- - {isCold ? - {dateLabel ? ( - {dateLabel} - ) : null} -
- ) -} - /** Replaces only the rows: the filter header row and the overview stay put. */ const MunicipalityListEmptyState = ({ state }: { state: MunicipalityListState }) => ( @@ -214,11 +126,6 @@ const advisorEntries = ( advisorNamesById: ReadonlyMap, ) => advisorEntriesFromIds(municipality.advisorIDs, advisorNamesById) -const advisorNames = ( - municipality: MunicipalityListViewModel, - advisorNamesById: ReadonlyMap, -): 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}

-
- {municipalities.length === 0 ? : null} - {municipalities.map((municipality) => { - const names = advisorNames(municipality, advisorNamesById) - const position = municipality.votePosition2022 - const isPriority = municipality.priority === 'alta' - const { region, zoneSuffix } = municipalityGeographyParts(municipality) - return ( -
-
-
- {/* - * 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. - */} -

- - {municipality.name} - -

-

- - - - {zoneSuffix ? ` ${zoneSuffix}` : null} -

- {position ? ( - - ) : null} -
- {isPriority && isStaffView ? ( - - ) : null} -
- {isStaffView ? ( -
-
-
Classe
-
- {/* - * 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. - */} - - - - - -
-
-
-
- {municipalityColumnLabels.goalCoverage} -
-
- -
-
-
-
Votos estimados
-
- -
-
-
-
Nível
-
- {props.canMoveEngagementLevel ? ( - - ) : ( - - )} -
-
-
-
Tendência
-
- -
-
-
-
Último sinal
-
- - - -
-
-
-
Assessores
-
- {isCoordinator ? ( - - ) : names.length ? ( - names.join(', ') - ) : ( - - )} -
-
-
- ) : null} -
- ) - })} -
+ } + /> {/* A sticky can't paint the row border, hence the inset shadow. */} {submitButton} - } - > - Cancelar - + Cancelar ) : undefined } diff --git a/src/components/campaign/municipality/MunicipalityListMobileCards.tsx b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx new file mode 100644 index 00000000..36206150 --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx @@ -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 + +export type MunicipalityListMobileCardsProps = { + municipalities: MunicipalityListViewModel[] + advisorNamesById: ReadonlyMap + 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) => ( +
+ {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 ( +
+
+
+

+ + {municipality.name} + +

+

+ + + + {zoneSuffix ? ` ${zoneSuffix}` : null} +

+ {position ? ( + + ) : null} +
+ {isPriority && isStaffView ? ( + + ) : null} +
+ {isStaffView ? ( +
+
+
Classe
+
+ +
+
+
+
{municipalityColumnLabels.goalCoverage}
+
+ +
+
+
+
Votos estimados
+
+ +
+
+
+
Nível
+
+ {canMoveEngagementLevel ? ( + + ) : ( + + )} +
+
+
+
Tendência
+
+ +
+
+
+
Último sinal
+
+ + + +
+
+
+
Assessores
+
+ {isCoordinator ? ( + + ) : names.length ? ( + names.join(', ') + ) : ( + + )} +
+
+
+ ) : null} +
+ ) + })} +
+) diff --git a/src/components/campaign/municipality/MunicipalityListMobileSection.tsx b/src/components/campaign/municipality/MunicipalityListMobileSection.tsx new file mode 100644 index 00000000..f77db0cf --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityListMobileSection.tsx @@ -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) => ( + + + +) diff --git a/src/components/campaign/municipality/MunicipalityListRowReadouts.tsx b/src/components/campaign/municipality/MunicipalityListRowReadouts.tsx new file mode 100644 index 00000000..76cfa2da --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityListRowReadouts.tsx @@ -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 ( + + + {why} + + ) + } + + return ( + <> + + {territorialClassLabels[municipality.territorialClass]} + + {why} + + ) +} + +/** 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 ( +
+ + {isCold ? + {dateLabel ? ( + {dateLabel} + ) : null} +
+ ) +} + +export const TerritorialClassCardReadout = ({ + municipality, +}: { + municipality: MunicipalityListViewModel +}) => ( + + + + + +) diff --git a/src/components/campaign/municipality/MunicipalityListSignalControl.tsx b/src/components/campaign/municipality/MunicipalityListSignalControl.tsx index 7960abfd..2cbe9adf 100644 --- a/src/components/campaign/municipality/MunicipalityListSignalControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListSignalControl.tsx @@ -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} - } - > - Cancelar - + Cancelar ) : undefined } diff --git a/src/components/campaign/municipality/WizardSignalTypeStep.tsx b/src/components/campaign/municipality/WizardSignalTypeStep.tsx index df61d9a4..a1272c29 100644 --- a/src/components/campaign/municipality/WizardSignalTypeStep.tsx +++ b/src/components/campaign/municipality/WizardSignalTypeStep.tsx @@ -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 = ({

{infoEntry.infoContent}

- }> - Fechar - + Fechar ) : null} diff --git a/src/components/campaign/shared/CampaignCellEditOverlay.tsx b/src/components/campaign/shared/CampaignCellEditOverlay.tsx index 32e0dff0..64ad6f50 100644 --- a/src/components/campaign/shared/CampaignCellEditOverlay.tsx +++ b/src/components/campaign/shared/CampaignCellEditOverlay.tsx @@ -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 = ({

) + 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} - - {/* - * 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. - */} - - - - {title} - - {/* Guarded, not required: every sheet today names its row, but one - opened from under a heading that already does would pass none. */} - {description ? {description} : null} - -
- {children} -
- - {footer ?? ( - // Swiping down is the gesture, but it is not an affordance - // anyone sees. - } + {open || everOpened ? ( + + {open ? ( + + + + {title} + + {description ? {description} : null} + +
- Fechar - - )} - - - + {children} +
+ + {footer ?? Fechar} + +
+ ) : null} +
+ ) : null} ) } diff --git a/src/components/campaign/shared/CampaignListSheetHost.tsx b/src/components/campaign/shared/CampaignListSheetHost.tsx new file mode 100644 index 00000000..2d882e7a --- /dev/null +++ b/src/components/campaign/shared/CampaignListSheetHost.tsx @@ -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 + footerPortalRef: RefObject + /** 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(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(null) + const bodyPortalRef = useRef(null) + const footerPortalRef = useRef(null) + const [open, setOpen] = useState(false) + const [chrome, setChrome] = useState(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 ( + + {children} + + {chrome && open ? ( + + + + {chrome.title} + + {chrome.description ? ( + {chrome.description} + ) : null} + +
+ +
+ {!chrome.hasCustomFooter ? Fechar : null} + + + ) : null} + + + ) +} diff --git a/src/components/campaign/shell/InstallPwaToast.tsx b/src/components/campaign/shell/InstallPwaToast.tsx index 13986937..c0944852 100644 --- a/src/components/campaign/shell/InstallPwaToast.tsx +++ b/src/components/campaign/shell/InstallPwaToast.tsx @@ -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 ? ( @@ -137,20 +136,9 @@ export const InstallPwaToast = () => {
  • Confirme tocando em Adicionar.
  • - - } - > - Entendi - + Entendi
    - ) + ) : null } diff --git a/src/components/ui/Drawer.tsx b/src/components/ui/Drawer.tsx index 4d6f0559..2ac55826 100644 --- a/src/components/ui/Drawer.tsx +++ b/src/components/ui/Drawer.tsx @@ -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) => ( ) +/** 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) => ( + ( +