diff --git a/docs/plans/detalhe-entidade-titulo-no-header-impl.md b/docs/plans/detalhe-entidade-titulo-no-header-impl.md new file mode 100644 index 00000000..a92d2771 --- /dev/null +++ b/docs/plans/detalhe-entidade-titulo-no-header-impl.md @@ -0,0 +1,77 @@ +# Impl: Detalhe de entidade — título no header (sem hero espaçoso) + +Status: aprovado +Atualizado em: 2026-08-02 +Issue: #315 +Intenção: docs/plans/detalhe-entidade-titulo-no-header.md +Appetite restante: ~0,5 dia eng (8 rotas de detalhe + catálogo + e2e) + +## Leitura da intenção + +- **Outcome:** Em cada detalhe de entidade em `/campanha`, a identidade do registro (título + subtítulo) vive no header da shell; o corpo começa direto em abas/seções/chips operacionais, sem hero nem “Voltar para…”. +- **O que NÃO negociar:** listas mantêm header de seção (B123/B133); edit routes inalteradas; chips operacionais no corpo; leader lockdown; aba do browser alinhada à entidade. +- **O que reavaliar:** hipótese de tocar só `campaignPageChrome` — insuficiente: títulos são dinâmicos por registro; precedente de `SetCampaignPageChrome` + `generateMetadata` nas rotas `/editar` é o dono. + +## Abordagem recomendada + +```mermaid +flowchart LR + RSC[Detail page RSC] --> MD[generateMetadata] + RSC --> SC[SetCampaignPageChrome] + SC --> Shell[CampaignPageChromeDisplay] + RSC --> Body[Corpo sem hero] +``` + +**Opções consideradas:** + +- A) Estender `resolveCampaignPageChrome` com dados de entidade (impossível — catálogo é estático/client-safe) +- B) `SetCampaignPageChrome` + `generateMetadata` por rota (precedente edit routes) +- C) Novo provider server-side de chrome + +**Recomendação:** B — reusa `SetCampaignPageChrome` e `campaignPageMetadata` já usados em `/editar` e `MunicipalityListPageChrome`; path rules de detalhe passam a `null` (como edit) para não flashar título de seção antes do override. + +**Rejeitadas:** C (twin desnecessário); A (dados dinâmicos não cabem no catálogo). + +### Componentes / mudanças + +- **`campaignPageChrome.ts`:** regras de detalhe retornam `null`; teste unitário atualizado. +- **8 rotas de detalhe** (`municipios/[slug]`, `liderancas/[id]`, `atividades/[slug]`, `demandas/[slug]`, `dobradinhas/[slug]`, `organizacoes/[slug]`, `apoiadores/[id]`, `assessores/[id]`): `generateMetadata` + `SetCampaignPageChrome`; remover hero (`h1`, “Voltar…”, chips de identidade duplicados, linha Assessoria no município). +- **`campaignZoneMap.e2e.spec.ts`:** assert no chrome da shell (`data-slot=campaign-page-chrome-title`) em vez de `h1` no corpo. +- **Migration:** sem migration +- **Access / Consent:** inalterado + +### Mapeamento chrome por rota + +| Rota | title | subtitle | +| ------------------- | ----------------- | --------------------------------------- | +| municipios/[slug] | view.name | formatMunicipalityGeographyLabel(view) | +| liderancas/[id] | Liderança | leadership.name | +| atividades/[slug] | view.title | view.locationLabel ou municipality.name | +| demandas/[slug] | demand.title | `${kind} · ${municipalityName}` | +| dobradinhas/[slug] | stateDeputy.name | party (opcional) | +| organizacoes/[slug] | organization.name | organizationKindLabels[kind] | +| apoiadores/[id] | Apoiador | supporter.name | +| assessores/[id] | Assessor | advisor.name | + +## Fases verificáveis + +1. **Catálogo + testes** — path rules `null` para detalhes; unit spec +2. **UI — 8 páginas** — chrome override + remoção de hero; metadata dinâmica +3. **Gates** — `pnpm gate:fast`; push via `pnpm push` + +## Rabbit holes / Não escopo (engenharia) + +- Novo bloco UI para Assessoria no município +- Wizard, giros, perfil, contatos, rotas `/editar`, listas +- Refatorar `CampaignPageChromeText` (layout já suporta subtitle) + +## Riscos e mitigação + +- **Flash de título de seção:** path rule `null` + `useLayoutEffect` do override (mesmo que edit routes) +- **E2E que asserta `h1`:** atualizar para chrome slot +- **Atividade sem locationLabel:** fallback para `municipality?.name` + +## Débitos deferidos (capture-review-debts autônomo) + +- **Domain `*DetailPageChrome` builders** (score 4, defer_trigger): quando um 3º detalhe precisar de lógica de subtítulo compartilhada além do padrão SetCampaignPageChrome — extrair no módulo dono do view model, não HOF genérico. +- **`cache()` em loaders de detalhe** (score 3, defer_trigger): edit routes já duplicam fetch MD+página; otimizar loaders no dono quando houver evidência de custo em prod. diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx index 1d8b97f2..7b6f2bac 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx @@ -1,18 +1,18 @@ import config from '@payload-config' -import { ArrowLeftIcon, ShieldCheckIcon } from 'lucide-react' +import { ShieldCheckIcon } from 'lucide-react' import Link from 'next/link' import { notFound, redirect } from 'next/navigation' import { getPayload } from 'payload' import { setSupporterVoteIntentionFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions' import { ConsentText } from '@/components/campaign/shared/ConsentText' +import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' import { RemoveSupporterDataButton } from '@/components/campaign/supporter/RemoveSupporterDataButton' import { SupporterShareKit } from '@/components/campaign/supporter/SupporterShareKit' import { VoteIntentionControl } from '@/components/campaign/supporter/VoteIntentionControl' import { Badge } from '@/components/ui/Badge' -import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' +import { campaignPageMetadata } from '@/lib/campaignPageChrome' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadSupporterDetailConsentData, @@ -25,7 +25,21 @@ import { } from '@/utilities/supporter/supporterUi' import { parseSupporterId } from '@/utilities/supporter/supporterViewModels' -export const metadata = campaignPageMetadataFromCatalog('apoiadores') +export async function generateMetadata({ params }: SupporterDetailPageProps) { + const { id: rawId } = await params + const supporterId = parseSupporterId(rawId) + if (supporterId === null) return campaignPageMetadata({ title: 'Apoiador' }) + + const [user, payload] = await Promise.all([requireCampaignPageActor(), getPayload({ config })]) + if (!canAccessSupporterArea(user.role)) return campaignPageMetadata({ title: 'Apoiador' }) + + try { + const supporter = await loadSupporterDetailPageData(payload, user, supporterId) + return campaignPageMetadata({ title: 'Apoiador', subtitle: supporter.name }) + } catch { + return campaignPageMetadata({ title: 'Apoiador' }) + } +} type SupporterDetailPageProps = { params: Promise<{ id: string }> @@ -65,19 +79,11 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag return (
-
- -

{supporter.name}

-

- {supporter.city ?? 'Cidade não informada'} - {supporter.territory ? ` · ${supporter.territory}` : ''} -

-
+ +

+ {supporter.city ?? 'Cidade não informada'} + {supporter.territory ? ` · ${supporter.territory}` : ''} +

diff --git a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx index 89d4b78b..a11b028f 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx @@ -1,22 +1,34 @@ import config from '@payload-config' -import { ArrowLeftIcon, MapPinIcon } from 'lucide-react' +import { MapPinIcon } from 'lucide-react' import Link from 'next/link' import { notFound } from 'next/navigation' import { getPayload } from 'payload' import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell' import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/AdvisorPasswordResetButton' +import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { Badge } from '@/components/ui/Badge' -import { Button } from '@/components/ui/button' -import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' +import { campaignPageMetadata } from '@/lib/campaignPageChrome' import { formatBrazilianPhoneInput } from '@/lib/phone' import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor' import { loadAdvisorDetail } from '@/utilities/advisorData' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { sendAdvisorPasswordResetFormAction, updateAdvisorProfileFormAction } from '../formActions' -export const metadata = campaignPageMetadataFromCatalog('assessores') +export async function generateMetadata({ params }: AdvisorDetailPageProps) { + const { id: rawId } = await params + const advisorId = Number(rawId) + if (!Number.isInteger(advisorId) || advisorId <= 0) { + return campaignPageMetadata({ title: 'Assessor' }) + } + + const payload = await getPayload({ config }) + const advisor = await loadAdvisorDetail(payload, advisorId) + if (!advisor) return campaignPageMetadata({ title: 'Assessor' }) + + return campaignPageMetadata({ title: 'Assessor', subtitle: advisor.name }) +} type AdvisorDetailPageProps = { params: Promise<{ id: string }> @@ -39,19 +51,11 @@ export default async function AdvisorDetailPage({ params }: AdvisorDetailPagePro return ( -
- -

{advisor.name}

-

- E-mail e celular salvam sozinhos ao digitar. A carteira de municípios é editada no modo - edição da lista. -

-
+ +

+ E-mail e celular salvam sozinhos ao digitar. A carteira de municípios é editada no modo + edição da lista. +

diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx index e3abed34..f347fe97 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx @@ -13,11 +13,12 @@ import { ActivityTabNav } from '@/components/campaign/activity/ActivityTabNav' import { ActivityTaskChecklist } from '@/components/campaign/activity/ActivityTaskChecklist' import { ActivityUpdateFeed } from '@/components/campaign/activity/ActivityUpdateFeed' import { ActivityUpdateForm } from '@/components/campaign/activity/ActivityUpdateForm' +import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' import { CampaignQuickActionContextBridge } from '@/components/campaign/shell/CampaignQuickActionContextBridge' import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' +import { campaignPageMetadata } from '@/lib/campaignPageChrome' import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' import { activityKindLabels } from '@/lib/schemas/activity' import { getActivityDetailPageData } from '@/utilities/activityDetailPageData' @@ -35,7 +36,25 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { ActivityOverviewTab } from './ActivityOverviewTab' -export const metadata = campaignPageMetadataFromCatalog('atividades') +export async function generateMetadata({ params }: ActivityDetailPageProps) { + const { slug } = await params + if (!slug) return campaignPageMetadata({ title: 'Atividade' }) + + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) + + try { + const activeTab = 'overview' as const + const context = await resolveAccessibleActivityContext(payload, user, slug, activeTab) + const view = await getActivityDetailPageData(payload, user, context, activeTab, 1) + const subtitle = view.locationLabel || view.municipality?.name + return campaignPageMetadata(subtitle ? { title: view.title, subtitle } : { title: view.title }) + } catch { + return campaignPageMetadata({ title: 'Atividade' }) + } +} type ActivityDetailPageProps = { params: Promise<{ slug: string }> @@ -73,53 +92,51 @@ export default async function ActivityDetailPage({ const isStaff = isCampaignStaff(user) const canManageLifecycle = isStaff && view.status !== 'realizado' && view.status !== 'cancelado' + const chromeSubtitle = view.locationLabel || view.municipality?.name return (
+ -
-
-
-
-

{view.title}

- - {view.deputyPresent ? Deputado presente : null} -
-
- {activityKindLabels[view.kind]} - - {view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'Data a definir'} - - {view.locationLabel ? · {view.locationLabel} : null} -
-
-
- {isStaff ? ( - - ) : null} +
+
+ + {view.deputyPresent ? Deputado presente : null} + {activityKindLabels[view.kind]} + + {view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'Data a definir'} + +
+
+ {isStaff ? ( - {canManageLifecycle ? ( - <> - - - - ) : null} -
+ ) : null} + + {canManageLifecycle ? ( + <> + + + + ) : null}
-
+
diff --git a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx index f95970c6..6bd8caef 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx @@ -1,15 +1,15 @@ import config from '@payload-config' -import { ArrowLeftIcon, PaperclipIcon } from 'lucide-react' +import { PaperclipIcon } from 'lucide-react' import Link from 'next/link' import { notFound } from 'next/navigation' import { getPayload } from 'payload' import { DemandWorkflowCard } from '@/components/campaign/demand/DemandWorkflowCard' +import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { CampaignQuickActionContextSync } from '@/components/campaign/shell/CampaignQuickActionContextSync' import { Badge } from '@/components/ui/Badge' -import { Button } from '@/components/ui/button' -import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' +import { campaignPageMetadata } from '@/lib/campaignPageChrome' import { campaignDemandKindLabels, campaignDemandStatusLabels, @@ -24,7 +24,21 @@ import { transitionDemandFormAction, } from './formActions' -export const metadata = campaignPageMetadataFromCatalog('demandas') +export async function generateMetadata({ params }: DemandDetailPageProps) { + const { slug } = await params + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) + + const demand = await loadDemandDetail(payload, user, slug) + if (!demand) return campaignPageMetadata({ title: 'Demanda' }) + + return campaignPageMetadata({ + title: demand.title, + subtitle: `${campaignDemandKindLabels[demand.kind]} · ${demand.municipalityName}`, + }) +} type DemandDetailPageProps = { params: Promise<{ slug: string }> @@ -63,48 +77,44 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps return ( + 0 ? demand.municipalityId : undefined} demandSlug={demand.slug} /> -
- -
-

{demand.title}

- - {campaignDemandStatusLabels[demand.status]} - -
-

- {campaignDemandKindLabels[demand.kind]} ·{' '} - - {demand.municipalityName} - - {demand.activity ? ( - <> - {' '} - · Atividade:{' '} - - {demand.activity.title} - - - ) : null} - {demand.requesterName ? ` · Solicitada por ${demand.requesterName}` : ''} · Aberta em{' '} - {dateTimeFormatter.format(new Date(demand.createdAt))} -

-
+
+ + {campaignDemandStatusLabels[demand.status]} + +
+

+ + {demand.municipalityName} + + {demand.activity ? ( + <> + {' '} + · Atividade:{' '} + + {demand.activity.title} + + + ) : null} + {demand.requesterName ? ` · Solicitada por ${demand.requesterName}` : ''} · Aberta em{' '} + {dateTimeFormatter.format(new Date(demand.createdAt))} +

{demand.description ? (
diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/page.tsx index 5f1a5cd5..65f5db15 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/page.tsx @@ -1,19 +1,34 @@ import config from '@payload-config' -import { ArrowLeftIcon, HandshakeIcon, MapPinIcon } from 'lucide-react' +import { HandshakeIcon, MapPinIcon } from 'lucide-react' import Link from 'next/link' import { notFound } from 'next/navigation' import { getPayload } from 'payload' +import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { StateDeputyForm } from '@/components/campaign/stateDeputy/StateDeputyForm' import { Badge } from '@/components/ui/Badge' -import { Button } from '@/components/ui/button' -import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' +import { campaignPageMetadata } from '@/lib/campaignPageChrome' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadStateDeputyDetail } from '@/utilities/stateDeputyData' import { updateStateDeputyFormAction } from './formActions' -export const metadata = campaignPageMetadataFromCatalog('dobradinhas') +export async function generateMetadata({ params }: StateDeputyDetailPageProps) { + const { slug } = await params + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) + + const stateDeputy = await loadStateDeputyDetail(payload, user, slug) + if (!stateDeputy) return campaignPageMetadata({ title: 'Dobradinha' }) + + return campaignPageMetadata( + stateDeputy.party + ? { title: stateDeputy.name, subtitle: stateDeputy.party } + : { title: stateDeputy.name }, + ) +} type StateDeputyDetailPageProps = { params: Promise<{ slug: string }> @@ -31,21 +46,16 @@ export default async function StateDeputyDetailPage({ params }: StateDeputyDetai return ( -
- -
-

{stateDeputy.name}

- {stateDeputy.party ? {stateDeputy.party} : null} -
- {stateDeputy.notes ? ( -

{stateDeputy.notes}

- ) : null} -
+ + {stateDeputy.notes ? ( +

{stateDeputy.notes}

+ ) : null}
@@ -58,34 +69,24 @@ export default async function LeadershipDetailPage({ params }: LeadershipDetailP return ( + -
- -
-

{leadership.name}

- {leadership.supportStatus ? ( - - ) : null} - - {leadership.hasAppAccess ? 'Com acesso ao app' : 'Sem acesso ao app'} - -
-

- {leadership.phone ? `Celular ${leadership.phone}` : 'Sem celular registrado'} - {leadership.email ? ` · ${leadership.email}` : ''} -

- {leadership.stateDeputies.length > 0 ? ( - - ) : null} -
+
+ {leadership.supportStatus ? : null} + + {leadership.hasAppAccess ? 'Com acesso ao app' : 'Sem acesso ao app'} + +
+

+ {leadership.phone ? `Celular ${leadership.phone}` : 'Sem celular registrado'} + {leadership.email ? ` · ${leadership.email}` : ''} +

+ {leadership.stateDeputies.length > 0 ? ( + + ) : null}
searchParams: Promise } -const dateFormatter = new Intl.DateTimeFormat('pt-BR') - export default async function MunicipalityDetailPage({ params, searchParams, @@ -68,22 +78,12 @@ export default async function MunicipalityDetailPage({ return ( - {/* print: the dossier tab carries its own capa; page chrome stays out. */} -
-
-

{view.name}

- {municipalityKindLabels[view.kind]} -
-

{formatMunicipalityGeographyLabel(view)}

-

- {advisorSummaries.length - ? `Assessoria: ${advisorSummaries.map((advisor) => advisor.name).join(', ')}` - : 'Sem assessor designado.'} - {view.lastUpdateAt - ? ` · Última atualização em ${dateFormatter.format(new Date(view.lastUpdateAt))}` - : ''} -

-
+ @@ -38,26 +52,18 @@ export default async function OrganizationDetailPage({ params }: OrganizationDet return ( -
- -
-

{organization.name}

- {organizationKindLabels[organization.kind]} -
- {organization.municipalityNames.length ? ( -

- Atua em {organization.municipalityNames.join(', ')} -

- ) : null} - {organization.notes ? ( -

{organization.notes}

- ) : null} -
+ + {organization.municipalityNames.length ? ( +

Atua em {organization.municipalityNames.join(', ')}

+ ) : null} + {organization.notes ? ( +

{organization.notes}

+ ) : null}
({ title }) - type PathRule = { match: (pathname: string) => boolean resolve: () => CampaignPageChrome | null @@ -128,12 +126,12 @@ const pathRules: PathRule[] = [ }, { match: (pathname) => /^\/campanha\/municipios\/[^/]+$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.municipios.title), + resolve: () => null, }, { // B147 parallel v2 — soft-dep B145 for entity name in chrome; section title for now. match: (pathname) => /^\/campanha\/municipio\/[^/]+\/v2$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.municipios.title), + resolve: () => ({ title: campaignPageChromeCatalog.municipios.title }), }, { match: (pathname) => pathname === '/campanha/territorios', @@ -145,7 +143,7 @@ const pathRules: PathRule[] = [ }, { match: (pathname) => /^\/campanha\/liderancas\/[^/]+$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.liderancas.title), + resolve: () => null, }, { match: (pathname) => pathname === '/campanha/liderancas', @@ -157,7 +155,7 @@ const pathRules: PathRule[] = [ }, { match: (pathname) => /^\/campanha\/dobradinhas\/[^/]+$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.dobradinhas.title), + resolve: () => null, }, { match: (pathname) => pathname === '/campanha/dobradinhas', @@ -177,7 +175,7 @@ const pathRules: PathRule[] = [ }, { match: (pathname) => /^\/campanha\/atividades\/[^/]+$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.atividades.title), + resolve: () => null, }, { match: (pathname) => pathname === '/campanha/atividades', @@ -189,7 +187,7 @@ const pathRules: PathRule[] = [ }, { match: (pathname) => /^\/campanha\/demandas\/[^/]+$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.demandas.title), + resolve: () => null, }, { match: (pathname) => pathname === '/campanha/demandas', @@ -205,7 +203,7 @@ const pathRules: PathRule[] = [ }, { match: (pathname) => /^\/campanha\/apoiadores\/[^/]+$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.apoiadores.title), + resolve: () => null, }, { match: (pathname) => pathname === '/campanha/apoiadores', @@ -213,7 +211,7 @@ const pathRules: PathRule[] = [ }, { match: (pathname) => /^\/campanha\/assessores\/[^/]+$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.assessores.title), + resolve: () => null, }, { match: (pathname) => pathname === '/campanha/assessores', @@ -225,7 +223,7 @@ const pathRules: PathRule[] = [ }, { match: (pathname) => /^\/campanha\/organizacoes\/[^/]+$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.organizacoes.title), + resolve: () => null, }, { match: (pathname) => pathname === '/campanha/organizacoes', diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts index c2ef7c8d..d7cef40d 100644 --- a/tests/e2e/campaignActivity.e2e.spec.ts +++ b/tests/e2e/campaignActivity.e2e.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from './fixtures/campaignE2EFixtures.js' +import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js' test.describe('Atividades — registro-fundação', () => { test.setTimeout(90_000) @@ -34,7 +34,7 @@ test.describe('Atividades — registro-fundação', () => { await page.getByRole('button', { name: 'Criar atividade' }).click() await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+$/) - await expect(page.getByRole('heading', { name: activityTitle })).toBeVisible({ + await expect(campaignPageChrome(page, activityTitle)).toBeVisible({ timeout: 15_000, }) await expect(page.getByText('Obrigação política', { exact: true })).toBeVisible() diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index f4402c0f..7fc2fb85 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -52,7 +52,7 @@ test.describe('Municípios — jornadas por papel', () => { ).toBeVisible() await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`) - await expect(page.getByRole('heading', { name: municipality.name })).toBeVisible() + await expect(campaignPageChrome(page, municipality.name)).toBeVisible() await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}/editar`) await page.getByLabel(`${advisor.name} `, { exact: false }).check() @@ -75,6 +75,9 @@ test.describe('Municípios — jornadas por papel', () => { // unique, unambiguous string rather than a bare "5.000" substring — // that also matches the (opacity-0 but DOM-visible) hover-preview span. await expect(page.getByText('Otimista: 5.000')).toBeVisible() + + // B145: assessoria left the detail hero; it still appears on the dossiê tab. + await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}?tab=dossie`) await expect(page.getByText(`Assessoria: ${advisor.name}`)).toBeVisible() }) @@ -360,7 +363,7 @@ test.describe('Municípios — jornadas por papel', () => { await page.getByLabel('Município').selectOption({ label: municipality.name }) await page.getByLabel('Detalhe a necessidade').fill('Precisamos para a caminhada de sábado.') await page.getByRole('button', { name: 'Abrir demanda' }).click() - await expect(page.getByRole('heading', { name: demandTitle })).toBeVisible() + await expect(campaignPageChrome(page, demandTitle)).toBeVisible() await expect(page.getByRole('button', { name: 'Aprovar' })).toBeVisible() await page.goto(`${campaign.baseURL}/campanha/demandas`) @@ -422,7 +425,7 @@ test.describe('Municípios — cards no celular (B42)', () => { // overlay receives it (as a user's finger would), and the card navigates. await card.getByText('Tendência', { exact: true }).click({ force: true }) await expect(page).toHaveURL(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`) - await expect(page.getByRole('heading', { name: municipality.name })).toBeVisible() + await expect(campaignPageChrome(page, municipality.name)).toBeVisible() }) }) diff --git a/tests/e2e/campaignZoneMap.e2e.spec.ts b/tests/e2e/campaignZoneMap.e2e.spec.ts index fa70bd37..2ec97438 100644 --- a/tests/e2e/campaignZoneMap.e2e.spec.ts +++ b/tests/e2e/campaignZoneMap.e2e.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from './fixtures/campaignE2EFixtures.js' +import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js' /** * B8 F2 — Salvador used to be one polygon carrying the whole city's votes, so @@ -49,9 +49,7 @@ test('the dashboard map paints Salvador zone by zone and opens one', async ({ ca await expect(readout).toContainText(/Salvador — ZE \d+/) await readout.getByRole('link', { name: 'Abrir município' }).click() - // The slug is the assertion: the detail page's own

reads the municipality - // ROW, which other specs rename, while the polygon's name comes from the - // static catalog. + // B145: entity identity lives in the shell header, not a body h1. await expect(page).toHaveURL(/\/campanha\/municipios\/salvador-ze-\d+$/) - await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + await expect(campaignPageChrome(page, /Salvador — ZE \d+/)).toBeVisible() }) diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts index 8d5b145e..5ca937a3 100644 --- a/tests/e2e/fixtures/campaignE2EFixtures.ts +++ b/tests/e2e/fixtures/campaignE2EFixtures.ts @@ -561,11 +561,11 @@ export const test = base.extend({ }, }) -export const campaignPageChrome = (page: Page, title: string) => +export const campaignPageChrome = (page: Page, title: string | RegExp) => page .locator('[data-slot="campaign-page-chrome"]') .filter({ visible: true }) .locator('[data-slot="campaign-page-chrome-title"]') - .getByText(title, { exact: true }) + .getByText(title, typeof title === 'string' ? { exact: true } : undefined) export { expect } diff --git a/tests/unit/campaignPageChrome.unit.spec.ts b/tests/unit/campaignPageChrome.unit.spec.ts index 93b257a4..ce4ef71f 100644 --- a/tests/unit/campaignPageChrome.unit.spec.ts +++ b/tests/unit/campaignPageChrome.unit.spec.ts @@ -21,10 +21,13 @@ describe('campaignPageChrome', () => { }) }) - it('resolves detail pages with section title only', () => { - expect(resolveCampaignPageChrome('/campanha/municipios/cairu', 'coordinator')).toEqual({ - title: 'Municípios', - }) + it('returns null for entity detail pages (chrome set per route)', () => { + expect(resolveCampaignPageChrome('/campanha/municipios/cairu', 'coordinator')).toBeNull() + expect(resolveCampaignPageChrome('/campanha/liderancas/42', 'advisor')).toBeNull() + expect(resolveCampaignPageChrome('/campanha/atividades/foo', 'coordinator')).toBeNull() + }) + + it('keeps section title on parallel municipio v2 route (B147 soft-dep)', () => { expect(resolveCampaignPageChrome('/campanha/municipio/cairu/v2', 'coordinator')).toEqual({ title: 'Municípios', })