Dim the tour composer proposal when changing identity territory.
Wire CampaignListPendingBoundary/Results on /campanha/atividades/giros so TI navigation matches list pending UX; pin contract in tourRegionPending unit spec.
This commit is contained in:
parent
ead5358847
commit
be0d587005
7 changed files with 207 additions and 51 deletions
|
|
@ -7,6 +7,7 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **Fill-in ✓ dimir resultado no compositor de giro (2026-07-28):** em `/campanha/atividades/giros`, `CampaignListPendingBoundary` + `CampaignListResults` dimam a proposta ao trocar TI; `TourRegionPicker` usa `useCampaignListTransition` (spinner do select permanece); `CalendarPhaseNote` dentro de Results; unit `tourRegionPending.unit.spec.ts`. Sem migration/action/`Consent`. Critique sem P0/P1. Plano: [`feedback-pendente-compositor-giro.md`](../../../docs/plans/feedback-pendente-compositor-giro.md).
|
||||
- **Fill-in ícone de prioridade na lista entregue em código (2026-07-28):** em `/campanha/municipios`, o Badge destrutivo "Prioritária" na coluna do nome (tabela) e no cabeçalho do card mobile (`priority === 'alta'`, staff) virou `MunicipalityPriorityIndicator` — `FlagIcon` `text-primary`, `aria-label` e `CampaignHoverTooltip` (B23 ✓) com copy fixa **"Município prioritário"** (filtro/forms/StrategyCard/Dossier continuam no rótulo curto "Prioritária"). No card, `className="relative size-11"` no trigger para ficar acima do link esticado B42. Ilha client mínima; `MunicipalityList` segue RSC. Pin unit: staff vê `aria-label="Município prioritário"`; liderança não. Sem migration/action/Consent. Gate: tsc/lint/format/cycles; unit 770; knip P3 pré-existente; Aikido 0 findings no indicador. Impeccable B; harden/optimize out. Plano: [`icone-prioridade-lista-municipios.md`](../../../docs/plans/icone-prioridade-lista-municipios.md).
|
||||
- **Fill-in remover lista de zonas do Início entregue em código (2026-07-28):** sumiu o bloco "Municípios por zona eleitoral" sob o mapa de `/campanha`; `zoneBreakdown` / `MunicipalityZoneBreakdownRow` / `zoneVotes*BySlug` saíram do contrato (`municipalityMapContract`) e do loader (`municipalityMapData`); a ressalva de aproximação do **B8+ F3 ✓** permanece gated por `hasZoneMunicipalities`. Int `municipalityMapData` e `TESTING.md` atualizados. Sem migration/action/Consent. Plano: [`remover-lista-zonas-mapa-inicio.md`](../../../docs/plans/remover-lista-zonas-mapa-inicio.md).
|
||||
- **Fill-in "Selecionados no topo nos filtros do header" entregue em código (2026-07-28):** opções já marcadas sobem ao topo nos Popovers multi-seleção do sistema de listas (municípios, lideranças, territórios, dobradinhas) e nos `CampaignMobileMultiFilterField`. Helper puro `orderFilterOptionsSelectedFirst` em `src/lib/listFilterOptions.ts`; snapshot da seleção na abertura do Popover (ordem congelada enquanto aberto; checkbox continua live); hairline entre grupos, oculto durante a busca (polish do critique). Gate multi = `optionRows.every(checkbox)` — single-select intocado. A premissa "não extrair head genérico" do plano caducou: B21/B29 já tinham pago `CampaignHeaderFilterPopover` compartilhado, então o item pousou em 2 arquivos e 4 listas. Specs unit do helper + do Popover (freeze). Sem migration/action/Consent. Plano: [`selecionados-no-topo-filtros-header.md`](../../../docs/plans/selecionados-no-topo-filtros-header.md).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
timestamp: 2026-07-28T18-26-19Z
|
||||
slug: src-app-campaign-campanha-app-atividades-giros
|
||||
---
|
||||
|
||||
# Critique: /campanha/atividades/giros pending feedback
|
||||
|
||||
Method: dual-agent attempted (A interrupted hung; B d63ec5de clean). Assessment A synthesized in parent from source. Detector: 0 findings. Browser skipped (no campaign server).
|
||||
|
||||
## Scoped Design Health ~3.6/4
|
||||
|
||||
Visibility of system status 4 — spinner on control + dim/aria-busy/live on results.
|
||||
Consistency 4 — same CampaignListPending\* as lists.
|
||||
AI slop: pass.
|
||||
|
||||
## Strengths
|
||||
|
||||
1. Shared transition without a second pending language.
|
||||
2. Control outside Results (Feel the action).
|
||||
3. Unit pin on aria-busy/data-pending.
|
||||
|
||||
## Priority issues
|
||||
|
||||
None P0/P1.
|
||||
|
||||
## Decisions confirmed
|
||||
|
||||
Keep "Atualizando resultados…"; keep select spinner + dim (gesture vs scope).
|
||||
|
||||
## Polish
|
||||
|
||||
No code changes — aligned with municipios nesting. CalendarPhaseNote under picker inside Results.
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
# Dimir o resultado no compositor de giro (feedback pendente)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-27
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos)
|
||||
Status: entregue (2026-07-28)
|
||||
Atualizado em: 2026-07-28
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos — riscado)
|
||||
Impeccable: B — encaixe em `/campanha/atividades/giros` (rota entregue no **E13 ✓**); nenhuma superfície nova
|
||||
Appetite: ~0,25 dia eng; três pontos de edição, sem migration/action/`Consent`
|
||||
Responsável: —
|
||||
|
||||
Revisão 2026-07-28 (auditoria pré-implementação): questões em aberto fechadas (copy aria-live reutilizada; spinner do select permanece). `CalendarPhaseNote` hoje renderiza _acima_ do picker — a entrega move a nota para dentro de `CampaignListResults` (abaixo do seletor), como a abordagem já descrevia.
|
||||
|
||||
**As-built 2026-07-28:** `CampaignListPendingBoundary` + `CampaignListResults` em `giros/page.tsx`; `TourRegionPicker` passou a `useCampaignListTransition`; `CalendarPhaseNote` dentro de Results; unit `tourRegionPending.unit.spec.ts` pina `aria-busy`/`data-pending` com `push` Promise pendente (React 19). Critique: sem P0/P1; polish sem mudança de código. Gate tsc/lint/format/cycles; knip P3 pré-existente (`payload.config.ts`); Aikido 0 findings nos first-party.
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (clareza sob pressão) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · precedente `engineering-standards.mdc` § Loading feedback e as 10 listas de `/campanha` que já usam `CampaignListPendingBoundary` + `CampaignListResults`.
|
||||
|
|
@ -53,10 +57,10 @@ Dois efeitos concretos, além da inconsistência com as dez listas vizinhas:
|
|||
- **Nada de `loading.tsx` na rota.** A navegação é `router.push` dentro de uma transição, então o segmento não remonta; um `loading.tsx` só apareceria na entrada direta, que já é rápida. **Rejeitado:** skeleton do compositor (a proposta anterior dimada é mais informativa que um esqueleto).
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês; a copy visível reusa "Atualizando resultados…" já existente, sem nova string.
|
||||
|
||||
## Questões em aberto
|
||||
## Questões resolvidas (2026-07-28)
|
||||
|
||||
- **A copy `aria-live` "Atualizando resultados…" serve para uma proposta de giro?** **Opções:** A) reusar como está | B) prop de mensagem no `CampaignListResults` para dizer "Recompondo o giro…". **Recomendação:** **A** — "resultados" descreve honestamente o que muda, e um prop novo abriria a porta para dez variações de copy nas listas; revisitar no critique se soar errado em voz alta.
|
||||
- **O spinner atual do `FieldDescription` fica ou sai?** **Opções:** A) fica (controle + resultado sinalizam) | B) sai, deixando só o dim. **Recomendação:** **A** — é o feedback imediato no ponto do gesto; o dim é o que informa o escopo. Custa duas linhas manter.
|
||||
- **Copy `aria-live`:** reusar "Atualizando resultados…" (sem prop de mensagem no `CampaignListResults`). Revisitar no critique se soar errado em voz alta.
|
||||
- **Spinner do `FieldDescription`:** permanece — feedback imediato no gesto; o dim informa o escopo.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -6,6 +6,10 @@ import { redirect } from 'next/navigation'
|
|||
import { getPayload } from 'payload'
|
||||
|
||||
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
|
||||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { CalendarPhaseNote } from '@/components/campaign/tour/CalendarPhaseNote'
|
||||
import { TourComposerForm, type TourStopOption } from '@/components/campaign/tour/TourComposerForm'
|
||||
|
|
@ -124,49 +128,54 @@ export default async function TourComposerPage({ searchParams }: TourComposerPag
|
|||
</Button>
|
||||
</header>
|
||||
|
||||
{bundle ? <CalendarPhaseNote phase={bundle.phase} /> : null}
|
||||
{/* Shared transition: the picker navigates, the proposal dims ("Feel the action"). */}
|
||||
<CampaignListPendingBoundary>
|
||||
{regions.length > 0 ? (
|
||||
<TourRegionPicker
|
||||
regions={regions.map((option) => ({
|
||||
...option,
|
||||
href: buildTourComposerHref({ region: option.region }),
|
||||
}))}
|
||||
selectedRegion={region}
|
||||
clearHref={buildTourComposerHref({ region: null })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{regions.length > 0 ? (
|
||||
<TourRegionPicker
|
||||
regions={regions.map((option) => ({
|
||||
...option,
|
||||
href: buildTourComposerHref({ region: option.region }),
|
||||
}))}
|
||||
selectedRegion={region}
|
||||
clearHref={buildTourComposerHref({ region: null })}
|
||||
/>
|
||||
) : null}
|
||||
<CampaignListResults>
|
||||
{bundle ? <CalendarPhaseNote phase={bundle.phase} /> : null}
|
||||
|
||||
{regions.length === 0 ? (
|
||||
<CampaignListEmptyState
|
||||
icon={MapPinnedIcon}
|
||||
title="Você ainda não acompanha nenhum município"
|
||||
description="A composição do giro sai da sua carteira. Peça ao Coordenador Geral para vincular seus municípios."
|
||||
/>
|
||||
) : region === null ? (
|
||||
<CampaignListEmptyState
|
||||
icon={MapPinnedIcon}
|
||||
title="Escolha um território para começar"
|
||||
description="A composição do giro compara os municípios entre si dentro do mesmo território — é assim que 'encaixe em giro' faz sentido."
|
||||
/>
|
||||
) : stops.length === 0 ? (
|
||||
<CampaignListEmptyState
|
||||
icon={MapPinnedIcon}
|
||||
title={`Nenhum município candidato em ${region}`}
|
||||
description="Nenhum município deste território está no seu escopo com dados suficientes para compor um giro. Cadastre lideranças ou compromissos de voto e volte aqui."
|
||||
>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/campanha/municipios">Ver municípios</Link>
|
||||
</Button>
|
||||
</CampaignListEmptyState>
|
||||
) : (
|
||||
<TourComposerForm
|
||||
region={region}
|
||||
stops={stops}
|
||||
defaultTourName={defaultTourName(region, now)}
|
||||
formAction={createTourDraftsFormAction}
|
||||
/>
|
||||
)}
|
||||
{regions.length === 0 ? (
|
||||
<CampaignListEmptyState
|
||||
icon={MapPinnedIcon}
|
||||
title="Você ainda não acompanha nenhum município"
|
||||
description="A composição do giro sai da sua carteira. Peça ao Coordenador Geral para vincular seus municípios."
|
||||
/>
|
||||
) : region === null ? (
|
||||
<CampaignListEmptyState
|
||||
icon={MapPinnedIcon}
|
||||
title="Escolha um território para começar"
|
||||
description="A composição do giro compara os municípios entre si dentro do mesmo território — é assim que 'encaixe em giro' faz sentido."
|
||||
/>
|
||||
) : stops.length === 0 ? (
|
||||
<CampaignListEmptyState
|
||||
icon={MapPinnedIcon}
|
||||
title={`Nenhum município candidato em ${region}`}
|
||||
description="Nenhum município deste território está no seu escopo com dados suficientes para compor um giro. Cadastre lideranças ou compromissos de voto e volte aqui."
|
||||
>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/campanha/municipios">Ver municípios</Link>
|
||||
</Button>
|
||||
</CampaignListEmptyState>
|
||||
) : (
|
||||
<TourComposerForm
|
||||
region={region}
|
||||
stops={stops}
|
||||
defaultTourName={defaultTourName(region, now)}
|
||||
formAction={createTourDraftsFormAction}
|
||||
/>
|
||||
)}
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTransition } from 'react'
|
||||
|
||||
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
|
|
@ -33,7 +33,7 @@ export const TourRegionPicker = ({
|
|||
clearHref: string
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const { isPending, startTransition } = useCampaignListTransition()
|
||||
|
||||
return (
|
||||
<Field className="max-w-md">
|
||||
|
|
|
|||
110
tests/unit/tourRegionPending.unit.spec.ts
Normal file
110
tests/unit/tourRegionPending.unit.spec.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, createElement, type ReactElement } from 'react'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const routerState = vi.hoisted(() => ({
|
||||
push: vi.fn(),
|
||||
/**
|
||||
* Resolves the in-flight `push` Promise so the React 19 transition can
|
||||
* settle. The shared pending boundary stays busy until this runs.
|
||||
*/
|
||||
resolvePush: null as (() => void) | null,
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', async (importActual) => ({
|
||||
...(await importActual()),
|
||||
useRouter: () => ({
|
||||
push: (...args: unknown[]) => routerState.push(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { TourRegionPicker } from '@/components/campaign/tour/TourRegionPicker'
|
||||
|
||||
/**
|
||||
* The composer isn't a list, but it reuses the list pending pieces. This pin
|
||||
* is the contract the E13 `/simplify` missed: changing Território drives the
|
||||
* shared transition so `CampaignListResults` dims (`data-pending` / `aria-busy`)
|
||||
* while the picker keeps its own spinner.
|
||||
*/
|
||||
const regions = [
|
||||
{ region: 'Sisal', municipalityCount: 3, href: '/campanha/atividades/giros?region=Sisal' },
|
||||
{
|
||||
region: 'Portal do Sertão',
|
||||
municipalityCount: 5,
|
||||
href: '/campanha/atividades/giros?region=Portal%20do%20Sert%C3%A3o',
|
||||
},
|
||||
]
|
||||
|
||||
const mountComposer = (): ReactElement =>
|
||||
createElement(
|
||||
CampaignListPendingBoundary,
|
||||
null,
|
||||
createElement(TourRegionPicker, {
|
||||
regions,
|
||||
selectedRegion: 'Sisal',
|
||||
clearHref: '/campanha/atividades/giros',
|
||||
}),
|
||||
createElement(CampaignListResults, null, createElement('p', null, 'Proposta do giro Sisal')),
|
||||
)
|
||||
|
||||
const reactActEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean
|
||||
}
|
||||
|
||||
describe('tour region pending', () => {
|
||||
beforeAll(() => {
|
||||
reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
delete reactActEnvironment.IS_REACT_ACT_ENVIRONMENT
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
routerState.push.mockReset()
|
||||
routerState.resolvePush = null
|
||||
})
|
||||
|
||||
it('dims the results region while the territory navigation is in flight', async () => {
|
||||
routerState.push.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
routerState.resolvePush = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
render(mountComposer())
|
||||
|
||||
const results = screen.getByText('Proposta do giro Sisal').parentElement
|
||||
expect(results).not.toBeNull()
|
||||
expect(results!.getAttribute('aria-busy')).not.toBe('true')
|
||||
expect(results!.getAttribute('data-pending')).toBeNull()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByLabelText('Território de Identidade'), {
|
||||
target: { value: 'Portal do Sertão' },
|
||||
})
|
||||
})
|
||||
|
||||
expect(routerState.push).toHaveBeenCalledWith(regions[1].href, { scroll: false })
|
||||
expect(results!.getAttribute('aria-busy')).toBe('true')
|
||||
expect(results!.getAttribute('data-pending')).toBe('true')
|
||||
expect(screen.getByText('Atualizando resultados…')).toBeTruthy()
|
||||
expect(screen.getByText('Montando a proposta de giro…')).toBeTruthy()
|
||||
expect((screen.getByLabelText('Território de Identidade') as HTMLSelectElement).disabled).toBe(
|
||||
true,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
routerState.resolvePush?.()
|
||||
})
|
||||
|
||||
expect(results!.getAttribute('aria-busy')).not.toBe('true')
|
||||
expect(results!.getAttribute('data-pending')).toBeNull()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue