diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 8ba0d7f8..3fc1a2ba 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-05):** **B158** (Issue #364) — `/campanha/municipios` troca cards↔tabela e revela colunas pela largura do painel de conteúdo com named container queries (48–84rem), sem rolagem horizontal; Território virou sublinha de Município, Tendência/Sinal preservam um único editor na tabela quando ocultos manualmente, e `campaign_columns=municipios:__none__` diferencia “todas visíveis” dos defaults. E2E mede cada limiar em Chromium e mantém um caso iPad-sized no projeto WebKit focado. Sem migration ou mudança de RBAC. Intenção [`colunas-responsivas-municipios.md`](docs/plans/colunas-responsivas-municipios.md); impl [`colunas-responsivas-municipios-impl.md`](docs/plans/colunas-responsivas-municipios-impl.md). + **Recently resolved (2026-08-05):** **B159** (Issue #374) — as colunas Assessores, Lideranças e Dobradinhas de `/campanha/municipios` agora compartilham `MunicipalityRelationEditor` (`CampaignCellEditOverlay` + Command + chips + optimistic reconcile) e `MunicipalityRelationAvatarStack`; Dobradinhas deixa `RelationChipCell` só nesta superfície, preserva busca por partido e links para a ficha, e Lideranças cria inline somente com nome (`Contact.phone = null`, sem stub; telefone real depois na ficha). Sem migration, rota nova ou mudança de access. Intenção [`generalizar-colunas-relacao-municipios.md`](docs/plans/generalizar-colunas-relacao-municipios.md); impl [`generalizar-colunas-relacao-municipios-impl.md`](docs/plans/generalizar-colunas-relacao-municipios-impl.md). **Recently resolved (2026-08-04):** **B155** (Issue #359) — coluna de lideranças na lista de municípios (espelho de Assessores): coluna "Lideranças" após "Assessores" (staff; leader fora), chips com nome do contato, popover/sheet com Command + delta otimista (seq/pending/rollback) e criação inline (mini-form Nome + Celular). **Sem migration**: leitura por query reversa de `leadership.municipalities` (batch no bundle, `loadMunicipalityLeadershipSummaries`) e escrita reusando B34 (`setLeadershipMunicipalitiesMembershipRecord`) + wizard create (`createLeadershipWizardRecord`) — fonte única de verdade, consistência automática com `/campanha/liderancas`. Rota nova `POST /campanha/municipios/leaderships` (`campaignJsonMutationRoute`, union toggle|create, safe-messages allowlist); provider bridge espelho do B154. Regra travada com o humano: **célula → rota, form → server action** (débito de migração das 3 anomalias — sinal da lista e colunas B31/B34 de `/campanha/liderancas`). Intenção [`liderancas-coluna-municipios.md`](docs/plans/liderancas-coluna-municipios.md); impl [`liderancas-coluna-municipios-impl.md`](docs/plans/liderancas-coluna-municipios-impl.md). diff --git a/docs/plans/colunas-responsivas-municipios-impl.md b/docs/plans/colunas-responsivas-municipios-impl.md new file mode 100644 index 00000000..191c72f4 --- /dev/null +++ b/docs/plans/colunas-responsivas-municipios-impl.md @@ -0,0 +1,190 @@ +# Impl: B158 — Colunas adaptativas na tabela de Municípios + +Status: aprovado +Atualizado em: 2026-08-05 +Issue: #364 +Intenção: docs/plans/colunas-responsivas-municipios.md +Appetite restante: ~1,5–2 dias eng; preservar o corte de um único outcome verificável + +## Leitura da intenção + +- **Outcome:** `/campanha/municipios` responde à largura real do painel de conteúdo: cards abaixo de 48rem; acima disso, uma tabela sem scroll horizontal que acrescenta contexto progressivamente, preserva preferência manual e mantém os quick edits acessíveis. +- **O que NÃO negociar:** container query em vez de viewport/JavaScript; ordem C0–C6; `leader` continua fora da rota; Dobradinha só para coordinator/candidate; território continua filtrável e `sort=region` continua válido; nenhum editor duplicado entre coluna adaptativa e `actions`; nenhuma migration, collection, Consent, server action ou mudança de URL pública. +- **O que reavaliar:** a intenção sugere um seam em `CampaignTable`, mas o código atual já aceita `className` em cada head e `cellClassName` em cada célula; a política responsiva pode e deve permanecer no dono local. A frase “uma instância por linha” será verificada dentro da superfície de tabela, porque cards e tabela já são árvores irmãs simultâneas no DOM, ocultadas por CSS; desmontar a superfície inativa exigiria estado/JavaScript e reabriria B42 fora do appetite. + +## Abordagem recomendada + +```mermaid +flowchart TD + Cookie[Cookie campaign_columns] --> Parse[Parser preserva ausente vs vazio explícito] + Parse --> Defaults[Defaults só quando municipios está ausente] + Defaults --> Manual[Visibilidade manual no servidor] + Manual --> Columns[Definições locais de colunas] + Width[Named container municipality-list] --> Capacity[Classes C0 a C6] + Capacity --> Columns + Columns --> Cards[Cards abaixo de 48rem] + Columns --> Table[Tabela a partir de 48rem] + Manual --> Actions[actions somente para trend ou lastSignal ocultos] +``` + +### Decisão 1 — onde vive a política responsiva + +**Opções consideradas:** + +- A. Classes de container query locais nas definições de `MunicipalityList`, aplicadas simetricamente a head/cell. +- B. Generalizar `CampaignTableColumn` com prioridade/breakpoint responsivo. +- C. Medir largura com `ResizeObserver` e recalcular colunas no cliente. + +**Recomendação:** A — `CampaignTable` continua um módulo profundo para renderização e preferência manual; a matriz C0–C6 é política exclusiva de Municípios e todos os heads já aceitam `className`. Um record local de classes literais e tipadas evita divergência entre `` e `` sem criar API compartilhada prematura. + +**Rejeitadas:** B vazaria uma política de uma lista para todas as tabelas e transformaria breakpoints de produto em configuração genérica; C adicionaria hidratação, estado e risco de flicker para um problema resolvido nativamente por CSS. + +### Decisão 2 — ausência vs vazio explícito no cookie B17 + +**Opções consideradas:** + +- A. Sentinel reservado `__none__` no formato atual (`municipios:__none__`). +- B. Cookie novo em JSON/versionado, migrando todos os consumidores. +- C. Manter o cookie atual e guardar um segundo cookie de “configurado”. + +**Recomendação:** A — é retrocompatível, cabe no parser/serializer puro existente e não cria uma segunda fonte de verdade. O parser trata o sentinel somente como vazio explícito; ele nunca emerge como column ID. O serializer usa presença da chave para distinguir “ausente” de `[]`. + +**Rejeitadas:** B amplia a migração e o risco de decoding entre `document.cookie` e `cookies()` sem benefício; C duplica estado e permite combinações contraditórias. + +### Decisão 3 — montagem única de Tendência/Sinal + +**Opções consideradas:** + +- A. Definir uma função local por controle e chamá-la ou na coluna real ou em `actions`, conforme a ocultação manual resolvida no servidor. +- B. Renderizar coluna e `actions` sempre, escondendo uma cópia com CSS. +- C. Mover quick edits para um menu genérico de ações por linha. + +**Recomendação:** A — apenas uma célula chama cada factory por linha. A coluna adaptativa permanece montada ao variar capacidade e troca compacto ↔ completo apenas por CSS; `actions` só é incluída na definição quando `trend` e/ou `lastSignal` está manualmente oculta. + +**Rejeitadas:** B viola o contrato de uma instância e duplica estado/autosave; C remove edição “onde se vê”, muda o fluxo aprovado e cria uma abstração rasa. + +### Decisão 4 — prova de responsividade e compatibilidade + +**Opções consideradas:** + +- A. Apenas snapshots/static markup e inspeção manual. +- B. Unit/static markup + Playwright medindo o named container em cada corte + interação real com sidebar/chat + projeto WebKit focado. +- C. Expandir toda a suíte campaign para uma matriz permanente Chromium/WebKit. + +**Recomendação:** B — prova o mecanismo pelo tamanho do container, não por proxy de viewport, e cobre sticky/container queries em WebKit sem duplicar a suíte inteira. O spec focado roda no projeto Chromium já existente e em um projeto WebKit dedicado; CI instala somente os dois engines necessários. + +**Rejeitadas:** A não detecta overflow nem erros de corte; C aumenta muito o tempo/custo de CI para uma necessidade localizada. WebKit é a automação disponível no Linux para compatibilidade Safari; uma checagem em Safari/iPad físico continua sendo validação de lançamento, não gate reproduzível deste agente. + +### Componentes / mudanças + +- **`parseCampaignHiddenColumns` / `serializeCampaignHiddenColumns` / resolver de defaults** (`src/lib/campaignColumnVisibility.ts`): preservar chave ausente versus vazio explícito, declarar o sentinel reservado e o preset de Municípios (`goalCoverage`, `lastSignal`) sem alterar defaults das outras listas. +- **`readCampaignColumnVisibility`** (`src/utilities/campaignColumnVisibilityCookie.ts`): aplicar preset somente quando a entrada da lista está ausente; devolver `[]` quando o sentinel registra “todas visíveis”. +- **`CampaignColumnPicker`** (`src/components/campaign/shared/CampaignColumnPicker.tsx`): manter o fluxo atual; `Restaurar todas` já chama `apply([])` e passará a persistir vazio explícito por efeito do serializer. Ajustar comentários/testes, não criar estado novo. +- **`MunicipalityList`** (`src/components/campaign/municipality/MunicipalityList.tsx`): + - envolver cards+tabela em `@container/municipality-list` com seletor estável de teste; + - reordenar colunas para Nome, 2022, 2026, Nível, Classe, Assessor, Tendência, Liderança, Dobradinha, Cobertura, Sinal, Ações; + - mover `TerritoryLink` para a segunda linha do nome e retirar `region` da tabela/picker; + - declarar `MunicipalityTableColumnId = MunicipalityListColumnId | 'actions'` localmente; + - declarar classes C0–C6 como strings literais, reutilizadas em head/cell; + - calcular ocultação manual uma vez e incluir `actions` somente quando necessário; + - remover `overflow-x-auto` local e manter o wrapper sem scroller horizontal; + - preservar legenda/caption, inclusive `sort=region` ativo. +- **`MunicipalityListMobileCards`** (`src/components/campaign/municipality/MunicipalityListMobileCards.tsx`): trocar somente `md:hidden` pela variante do named container; conteúdo/interação ficam intactos. +- **`MunicipalityListTrendControl`** (`src/components/campaign/municipality/MunicipalityListTrendControl.tsx`): seam tipado de trigger adaptativo para a tabela; uma instância contém ícone compacto e badge/readout completo alternados por CSS. O botão continua com nome acessível textual e sem depender apenas de cor. +- **`MunicipalityListSignalControl`** (`src/components/campaign/municipality/MunicipalityListSignalControl.tsx`): manter o slot `children`; o chamador fornece, na mesma instância, trigger compacto e `SignalAgeReadout` completo. Em `actions`, fornecer somente o trigger compacto apropriado. +- **`MunicipalityListLevelControl` / `MunicipalityLevelBadge`** (`src/components/campaign/municipality/`): a face vazia na célula de tabela passa a `—`, preservando “Sem nível” no `aria-label`, filtro e formulários/cards. +- **Labels e URL** (`src/utilities/municipality/municipalityLabels.ts`, `src/utilities/municipality/municipalityListUrl.ts`): retirar `region` apenas de `MunicipalityListColumnId`/records de coluna; manter `region` em sort/parser/serializer/resumo. Separar label de picker (`Estimativa 2026`) do head telegráfico (`2026`) e usar singular nos demais heads aprovados. +- **Filtros** (`MunicipalityFilters.tsx`, `municipalityOmnibox.ts`): sem mudança comportamental; regressões garantem `regionFilterOptions`, chips e `?region=`. +- **Migration:** sem migration. +- **Access / Consent:** nenhuma mudança; a composição por papel continua usando `isStaffView` e `isCampaignUnrestricted` existentes. +- **UI:** Impeccable Adapt, modo Operate. Shape já está fixado pela matriz; execução segue craft → critique → polish em duas rodadas visuais no máximo. Reusar tokens e shells existentes; sem redesign. +- **Documentação:** entrada curta em `docs/CHANGELOG-AGENTS.md`; não alterar `docs/ARCHITECTURE.md`, pois nenhuma fronteira muda. + +### Dados → forma + +- **Forma escolhida:** tabela essencial + disclosure progressivo por coluna, com cards no container estreito. +- **Por quê:** nome/2022/2026/nível/classe sustentam decisão imediata; responsabilidade e rede entram à medida que o painel comporta; trend/sinal preservam ação por trigger compacto. +- **Rejeitadas:** scroll horizontal mantém informação mas esconde contexto fora da dobra; tabela fixa reduz legibilidade; cards em toda largura desperdiçam densidade operacional. + +## Fases verificáveis + +1. **Tracer: preferência + estrutura local (~30%)** + - Evoluir parser/serializer/defaults com unit tests de round-trip e legado. + - Reordenar/remover `region`, criar named container, trocar cards↔tabela em 48rem e aplicar C0 essencial. + - Rodar testes unitários focados e `pnpm gate:fast`. +2. **Controles adaptativos + matriz completa (~35%)** + - Implementar `actions`, factories únicas e estados compacto/completo. + - Aplicar C1–C6 simetricamente em heads/cells, headers curtos, nome+território e nível vazio. + - Craft com conteúdo real/nomes longos; calibrar cortes uma única vez por evidência de `scrollWidth`. +3. **Browser + compatibilidade (~25%)** + - Spec Playwright focado com larguras 1px antes/depois de 48/54/60/66/72/78/84rem. + - Medir `scrollWidth <= clientWidth`, visibilidade cards/tabela, ordem/entrada, defaults, `actions` e uma instância por controle dentro da tabela. + - Remover largura forçada e validar que sidebar e chat alteram a etapa sem mudar a viewport. + - Rodar o mesmo spec em Chromium e WebKit; incluir cenário de viewport iPad para sticky/touch targets. +4. **Polish e gates (~10%)** + - Critique/polish em lote, teclado/foco/nomes acessíveis e alvo mínimo de 44px. + - Atualizar changelog. + - `pnpm gate:fast`, testes E2E focados, `pnpm exec knip`, `pnpm check:cycles`; entrega via `pnpm push`. + +## Verificação prevista + +### Unit/static markup + +- `tests/unit/campaignColumnVisibility.unit.spec.ts`: ausente → sem chave; sentinel → chave com `[]`; round-trip explícito; entradas legadas e outras listas intactas. +- `tests/unit/campaignColumnPicker.unit.spec.ts`: “Restaurar todas” escreve `municipios:__none__`; toggles partem do preset recebido e persistem uma única sessão. +- `tests/unit/campaignComponents.unit.spec.ts`: ordem de headers por papel, `region` fora da tabela/picker, território sob nome, `actions` fora do picker, nível nulo `—`, classes simétricas e quantidade de triggers por cenário manual dentro do ``. +- `tests/unit/municipalityList.unit.spec.ts` e `municipalityOmnibox.unit.spec.ts`: `sort=region`, resumo, filtros/chips/serialização continuam válidos. + +### Browser/E2E + +- Novo spec focado `tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts`. +- Named container selecionável por atributo estável; largura controlada diretamente para provar os cortes. +- Defaults sem cookie: Cobertura/Sinal ocultos e ação de Sinal disponível; sentinel explícito: todas visíveis após reload. +- Cada faixa prova headers esperados, mudança compacta/completa e ausência de overflow. +- Sidebar/chat são acionados pelos controles reais; o teste compara a largura medida e a etapa resultante. +- Chromium desktop + WebKit focado; viewport iPad no cenário sticky/touch. + +### Fonte oficial + +- Tailwind CSS 4 container queries: `@container/{name}`, variantes nomeadas e valores arbitrários `@min-[…]`: https://tailwindcss.com/docs/responsive-design#container-queries + +## Rabbit holes / Não escopo (engenharia) + +- Não criar estado React, `ResizeObserver`, `matchMedia` ou hook de largura. +- Não adicionar política responsiva genérica a `CampaignTable` nem alterar `ui/Table` globalmente. +- Não desmontar a árvore cards/tabela com JavaScript; a montagem única é garantida entre coluna e `actions` na tabela. +- Não otimizar loaders por coluna visível. +- Não alterar conteúdo dos cards, o omnibox ou contratos de URL. +- Não converter toda a suíte E2E para uma matriz cross-browser. +- Não criar novos ícones/linguagem visual fora dos triggers adaptativos necessários. + +## Riscos e mitigação + +- **Tailwind não emitir classes dinâmicas:** manter todas as variantes C0–C6 como strings literais completas no arquivo; typecheck e build comprovam geração. +- **Head/cell divergirem:** usar um record local único por coluna e testes estáticos que verificam a mesma variante nos dois lados. +- **Overflow por conteúdo real, especialmente relações:** testar nomes longos e várias relações; compactar somente trend/sinal; calibrar cortes em uma rodada medida, sem loop de microajustes. +- **Sticky left em container sem scroller próprio:** testar Chromium/WebKit; manter fundo/z-index existentes e medir overflow do container. +- **Sentinel vazar como coluna:** parser reconhece o valor antes da validação de IDs; serializer nunca o aceita como entrada de usuário; tests pinam que `hiddenColumnIds` recebe `[]`. +- **Default novo parecer escolha explícita:** ausência aplica preset, mas a primeira interação escreve uma entrada explícita; “Restaurar todas” escreve sentinel e sobrevive a reload. +- **Custo CI WebKit:** projeto restrito ao spec B158, não à suíte campaign inteira. +- **Safari/iPad físico indisponível no ambiente Linux:** WebKit automatizado é gate técnico; inspeção física permanece checklist de lançamento, explicitamente não alegada como executada. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto. +- [ ] Invariantes AGENTS/engineering-standards preservados; sem schema, DB, access ou Consent. +- [ ] Preferência manual e capacidade do container permanecem dimensões independentes. +- [ ] Cada controle Trend/Sinal é montado no máximo uma vez por linha dentro da tabela. +- [ ] Testes unit/static cobrem cookie, papel, DOM e contratos URL/filtro. +- [ ] E2E mede container, overflow, shell real e compatibilidade Chromium/WebKit. +- [ ] Gate rápido, dead-code/cycles e push canônico previstos. + +## Self-score decision-quality + +1. Decisões caras têm alternativas rejeitadas: **1/1**. +2. Abordagem cabe no appetite: **1/1**. +3. Rabbit holes nomeados: **1/1**. +4. Depth check reusa owners/shells/helpers: **1/1**. +5. Outcome e lockdowns da intenção preservados: **1/1**. + +**Total: 5/5.** diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index 023ebabb..32c0b66a 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -1,4 +1,4 @@ -import { SearchXIcon } from 'lucide-react' +import { ActivityIcon, CircleAlertIcon, SearchXIcon } from 'lucide-react' import Link from 'next/link' import { @@ -69,6 +69,10 @@ import { resolveMunicipalityListSort, type MunicipalityListState, } from '@/utilities/municipality/municipalityListUrl' +import { + isMunicipalitySignalCold, + municipalitySignalAgeInDays, +} from '@/utilities/municipality/municipalitySignal' import type { EligibleAdvisorOption, EligibleLeadershipOption, @@ -155,8 +159,48 @@ const advisorEntries = ( * Desktop table columns (Pass 2 W1). `id` is the picker key, cookie key, and B22 * description key — typed instead of widening to `string`. */ +type MunicipalityTableColumnId = MunicipalityListColumnId | 'actions' + type MunicipalityColumn = CampaignTableColumn & { - id: MunicipalityListColumnId + id: MunicipalityTableColumnId +} + +const responsiveColumnClassName = { + advisors: 'hidden @min-[54rem]/municipality-list:table-cell', + leaderships: 'hidden @min-[66rem]/municipality-list:table-cell', + stateDeputies: 'hidden @min-[72rem]/municipality-list:table-cell', + goalCoverage: 'hidden @min-[78rem]/municipality-list:table-cell', +} as const satisfies Partial> + +const MunicipalityListSignalTrigger = ({ + lastSignalAt, + presentation, +}: { + lastSignalAt: string | null + presentation: 'compact' | 'adaptive' +}) => { + const isCold = isMunicipalitySignalCold(municipalitySignalAgeInDays(lastSignalAt)) + const Icon = isCold ? CircleAlertIcon : ActivityIcon + const compact = ( + ` itself, since `cellTooltip` (e.g. "Classe") and // the mobile card's own class tooltip (B42) add triggers of their own. const [, tbodyHtml = ''] = html.split('')) - expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(11) + expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(10) - // B41: horizontal scroll table with a pinned Município column header. + // B158: the table is container-driven and clips instead of delegating to a + // horizontal scroller. Município remains pinned and owns the territory subline. + expect(html).toContain('data-container="municipality-list"') + expect(html).toContain('@container/municipality-list') + expect(html).toContain('@min-[48rem]/municipality-list:block') + expect(html).toContain('@min-[48rem]/municipality-list:hidden') expect(html).toContain('data-slot="table-container"') + expect(html).toContain('supports-[container-type:inline-size]:overflow-x-hidden') + // overflow-x-auto is the fallback for browsers without container queries; + // the test above still proves the container-query path takes precedence. + expect(html).toContain('overflow-x-auto') expect(theadHtml).toContain('Município') expect(tbodyHtml).toContain('Seabra') }) @@ -338,11 +378,10 @@ describe('campaign visual foundation', () => { }), ) - // B157: the new column's header explanation makes it 12 staff tooltips - // (11 with B155's Lideranças column + this one). + // B157/B158: Dobradinha adds one explanation to the 10-column staff baseline. const theadHtml = html.slice(html.indexOf('')) - expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(12) - expect(theadHtml).toContain('Dobradinhas') + expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(11) + expect(theadHtml).toContain('Dobradinha') // Avatar-stack display: initials circles + the names in the trigger's // accessible label (the closed cell reads WHO is assigned without opening). expect(html).toContain('>F<') // Fulano @@ -663,10 +702,113 @@ describe('campaign visual foundation', () => { expect(html).not.toContain('Faltam') expect(html).not.toContain('Prioritária') expect(html).not.toContain(`aria-label="${municipalityPriorityIndicatorLabel}"`) - // B22: the leader view's 5 columns (name/region/kind/votos/lastUpdateAt) - // also carry header explanations. + // B22/B158: the leader view keeps only Município, 2022 and Atualização; + // all three carry header explanations. const [theadHtml] = html.split(' { + const renderResponsiveList = (hiddenColumnIds: string[] = []) => + renderWithAppRouter( + createElement(MunicipalityList, { + municipalities: [responsiveMunicipality], + advisorNamesById: new Map(), + ...municipalityListDefaultProps, + isStaffView: true, + isCampaignUnrestricted: true, + columnVisibility: { listId: 'municipios', hiddenColumnIds }, + }), + ) + + it('keeps the table order, territory subline, and symmetric container-query classes', () => { + const html = renderResponsiveList() + const theadHtml = html.slice(html.indexOf('')) + const tbodyHtml = html.slice(html.indexOf('')) + const labels = [ + 'Município', + '2022', + '2026', + 'Nível', + 'Classe', + 'Assessor', + 'Tendência', + 'Liderança', + 'Dobradinha', + 'Cobertura', + 'Sinal', + ] + + let previousIndex = -1 + for (const label of labels) { + const labelIndex = theadHtml.indexOf(label) + expect(labelIndex, `${label} deve aparecer depois da coluna anterior`).toBeGreaterThan( + previousIndex, + ) + previousIndex = labelIndex + } + + expect(theadHtml).not.toContain('Território') + const nameCellHtml = tbodyHtml.split('')[0]).toContain('>—') + expect(html).toContain('Sem nível') + }) + + it.each([ + { hidden: [], hasActions: false }, + { hidden: ['trend'], hasActions: true }, + { hidden: ['lastSignal'], hasActions: true }, + { hidden: ['trend', 'lastSignal'], hasActions: true }, + ])('mounts one trend and signal editor in the table for $hidden', ({ hidden, hasActions }) => { + const html = renderResponsiveList(hidden) + const tableHtml = html.slice(html.indexOf('')) + const theadHtml = tableHtml.slice(tableHtml.indexOf('')) + + expect(tableHtml.match(/aria-label="Editar tendência política/g)).toHaveLength(1) + expect(tableHtml.match(/aria-label="Registrar sinal em/g)).toHaveLength(1) + expect(theadHtml.includes('Ações')).toBe(hasActions) + }) + + it('keeps removed and internal columns out of the municipality picker', () => { + const columns = municipalityListPickerColumns({ + isStaffView: true, + isCampaignUnrestricted: true, + }) + + expect(columns.map((column) => column.id)).toEqual([ + 'name', + 'votos', + 'expectedVotes', + 'level', + 'classe', + 'advisors', + 'trend', + 'leaderships', + 'stateDeputies', + 'goalCoverage', + 'lastSignal', + ]) + expect(columns).not.toContainEqual(expect.objectContaining({ id: 'region' })) + expect(columns).not.toContainEqual(expect.objectContaining({ id: 'actions' })) + }) }) /** @@ -695,10 +837,10 @@ describe('campaign visual foundation', () => { }) it('wraps the advisor cell in a tooltip only when there is a name to show', () => { - // The staff `` already carries 11 header-explanation tooltips + // The staff `` already carries 10 header-explanation tooltips // (B22), regardless of row data. This baseline isolates the advisors // CELL tooltip, the one this test actually exercises. - const HEADER_TOOLTIP_COUNT = 11 + const HEADER_TOOLTIP_COUNT = 10 const baseMunicipality = { id: 1, name: 'Seabra',
can't paint the row border, hence the inset shadow. */} - - {sortSummary} - {showsVoteColumn ? `. Coluna 2022: ${municipalityColumnDescriptions.votos}` : null} - - } - columns={columns} - columnVisibility={props.columnVisibility} - rows={municipalities} - rowKey={(municipality) => municipality.id} - empty={} - /> + {/* A sticky can't paint the row border, hence the inset shadow. */} + + {sortSummary} + {showsVoteColumn ? `. Coluna 2022: ${municipalityColumnDescriptions.votos}` : null} + + } + columns={columns} + columnVisibility={props.columnVisibility} + rows={municipalities} + rowKey={(municipality) => municipality.id} + empty={} + /> + ) diff --git a/src/components/campaign/municipality/MunicipalityListLevelControl.tsx b/src/components/campaign/municipality/MunicipalityListLevelControl.tsx index e1fa9abc..ee50991d 100644 --- a/src/components/campaign/municipality/MunicipalityListLevelControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListLevelControl.tsx @@ -248,12 +248,17 @@ export const MunicipalityListLevelControl = ({ preventPopoverAutoFocus trigger={ // The sheet has no hover to carry the rest, so the trigger spells the - // level out there and stays the bare numeral in the table. - + // level out there. In the table, absence follows the shared cell pattern + // (`—`) while the button's aria-label still announces "Sem nível". + currentLevel || isSheet ? ( + + ) : ( + — + ) } >
diff --git a/src/components/campaign/municipality/MunicipalityListMobileCards.tsx b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx index af18665e..dc3e0bb5 100644 --- a/src/components/campaign/municipality/MunicipalityListMobileCards.tsx +++ b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx @@ -83,7 +83,10 @@ export const MunicipalityListMobileCards = ({ signalFormAction, emptySlot, }: MunicipalityListMobileCardsProps) => ( -
+
{municipalities.length === 0 ? emptySlot : null} {municipalities.map((municipality) => { const names = advisorEntriesFromIds(municipality.advisorIDs, advisorNamesById).map( diff --git a/src/components/campaign/municipality/MunicipalityListSignalControl.tsx b/src/components/campaign/municipality/MunicipalityListSignalControl.tsx index 2cbe9adf..a1c2423e 100644 --- a/src/components/campaign/municipality/MunicipalityListSignalControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListSignalControl.tsx @@ -78,7 +78,7 @@ export const MunicipalityListSignalControl = ({ triggerLabel={`Registrar sinal em ${municipalityName} — ${frescorLabel}`} triggerBusy={isPending} statusMessage={isPending ? 'Registrando sinal…' : ''} - triggerClassName="text-left" + triggerClassName="min-w-11 text-left" contentClassName="w-80" trigger={children} footer={ diff --git a/src/components/campaign/municipality/MunicipalityListTrendControl.tsx b/src/components/campaign/municipality/MunicipalityListTrendControl.tsx index 667eea68..7de8a7b5 100644 --- a/src/components/campaign/municipality/MunicipalityListTrendControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListTrendControl.tsx @@ -1,5 +1,6 @@ 'use client' +import { CircleHelpIcon, MinusIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import type { @@ -45,14 +46,22 @@ type MunicipalityListTrendControlProps = { status: MunicipalityListSavedPoliticalTrend['status'] trendNote: string | null variant: CampaignCellEditOverlayVariant + triggerPresentation?: 'full' | 'compact' | 'adaptive' } +const politicalTrendIcon = { + favoravel: TrendingUpIcon, + neutra: MinusIcon, + desfavoravel: TrendingDownIcon, +} as const + export const MunicipalityListTrendControl = ({ municipalityID, municipalityName, status, trendNote, variant, + triggerPresentation = 'full', }: MunicipalityListTrendControlProps) => { const { open, onOpenChange, value, change, flush, isPending, errorMessage, statusMessage } = useCampaignCellAutosave< @@ -88,6 +97,25 @@ export const MunicipalityListTrendControl = ({ const hasNote = Boolean(value.note) const trendLabel = value.status ? politicalTrendLabels[value.status] : 'Não registrada' + const TrendIcon = value.status ? politicalTrendIcon[value.status] : CircleHelpIcon + const badgeVariant = value.status ? politicalTrendBadgeVariant[value.status] : 'outline' + const fullTrigger = {trendLabel} + const compactTrigger = ( + + + ) + const trigger = + triggerPresentation === 'full' ? ( + fullTrigger + ) : triggerPresentation === 'compact' ? ( + compactTrigger + ) : ( + <> + {compactTrigger} + {fullTrigger} + + ) return ( {value.note}

: null} contentClassName="w-72 p-3" preventPopoverAutoFocus - trigger={ - value.status ? ( - {trendLabel} - ) : ( - {trendLabel} - ) - } + trigger={trigger} >
{isPending ? ( diff --git a/src/lib/campaignColumnVisibility.ts b/src/lib/campaignColumnVisibility.ts index 80b9ee53..8b5cdf6e 100644 --- a/src/lib/campaignColumnVisibility.ts +++ b/src/lib/campaignColumnVisibility.ts @@ -63,8 +63,13 @@ export const toCampaignColumnPickerColumns = ( const LIST_SEPARATOR = '|' const LIST_ID_SEPARATOR = ':' const COLUMN_SEPARATOR = '~' +const EXPLICIT_EMPTY_COLUMNS = '__none__' const COLUMN_ID_PATTERN = /^[A-Za-z0-9_-]+$/ +const DEFAULT_HIDDEN_COLUMN_IDS: Partial> = { + municipios: ['goalCoverage', 'lastSignal'], +} + const isCampaignListId = (value: string): value is CampaignListId => CAMPAIGN_LIST_IDS.some((listId) => listId === value) @@ -83,12 +88,19 @@ export const parseCampaignHiddenColumns = (raw: string | undefined): CampaignHid // An id matching nothing on the table is inert, so the length cap above is // the only bound worth having — a per-list one would also have to be // mirrored in `serialize`, or writing 41 and reading back 40 would differ. + const encodedColumns = entry.slice(separatorIndex + 1) + if (encodedColumns === EXPLICIT_EMPTY_COLUMNS) { + parsed[listId] = [] + continue + } + const columnIds = [ ...new Set( - entry - .slice(separatorIndex + 1) + encodedColumns .split(COLUMN_SEPARATOR) - .filter((columnId) => COLUMN_ID_PATTERN.test(columnId)), + .filter( + (columnId) => columnId !== EXPLICIT_EMPTY_COLUMNS && COLUMN_ID_PATTERN.test(columnId), + ), ), ] @@ -100,11 +112,27 @@ export const parseCampaignHiddenColumns = (raw: string | undefined): CampaignHid export const serializeCampaignHiddenColumns = (value: CampaignHiddenColumns): string => CAMPAIGN_LIST_IDS.flatMap((listId) => { - const columnIds = (value[listId] ?? []).filter((columnId) => COLUMN_ID_PATTERN.test(columnId)) - if (!columnIds.length) return [] - return [`${listId}${LIST_ID_SEPARATOR}${columnIds.join(COLUMN_SEPARATOR)}`] + if (!Object.prototype.hasOwnProperty.call(value, listId)) return [] + + const columnIds = (value[listId] ?? []).filter( + (columnId) => columnId !== EXPLICIT_EMPTY_COLUMNS && COLUMN_ID_PATTERN.test(columnId), + ) + const encodedColumns = columnIds.length + ? columnIds.join(COLUMN_SEPARATOR) + : EXPLICIT_EMPTY_COLUMNS + return [`${listId}${LIST_ID_SEPARATOR}${encodedColumns}`] }).join(LIST_SEPARATOR) +export const resolveCampaignColumnVisibility = ( + listId: CampaignListId, + hiddenColumns: CampaignHiddenColumns, +): CampaignColumnVisibility => ({ + listId, + hiddenColumnIds: Object.prototype.hasOwnProperty.call(hiddenColumns, listId) + ? (hiddenColumns[listId] ?? []) + : (DEFAULT_HIDDEN_COLUMN_IDS[listId] ?? []), +}) + /** * Records one toggle. Hiding is stored (never the visible set), so a column * shipped by a later item — E9's derived signals, E14's level — is visible to diff --git a/src/utilities/campaignColumnVisibilityCookie.ts b/src/utilities/campaignColumnVisibilityCookie.ts index 52d7f6db..ced3889f 100644 --- a/src/utilities/campaignColumnVisibilityCookie.ts +++ b/src/utilities/campaignColumnVisibilityCookie.ts @@ -5,6 +5,7 @@ import { cookies } from 'next/headers' import { CAMPAIGN_COLUMNS_COOKIE, parseCampaignHiddenColumns, + resolveCampaignColumnVisibility, type CampaignColumnVisibility, type CampaignListId, } from '@/lib/campaignColumnVisibility' @@ -20,5 +21,5 @@ export const readCampaignColumnVisibility = async ( const cookieStore = await cookies() const raw = cookieStore.get(CAMPAIGN_COLUMNS_COOKIE)?.value - return { listId, hiddenColumnIds: parseCampaignHiddenColumns(raw)[listId] ?? [] } + return resolveCampaignColumnVisibility(listId, parseCampaignHiddenColumns(raw)) } diff --git a/src/utilities/municipality/municipalityLabels.ts b/src/utilities/municipality/municipalityLabels.ts index 2e0ab38e..d207ba78 100644 --- a/src/utilities/municipality/municipalityLabels.ts +++ b/src/utilities/municipality/municipalityLabels.ts @@ -182,7 +182,6 @@ export const formatMunicipalityGeographyLabel = (municipality: { */ export type MunicipalityListColumnId = | 'name' - | 'region' | 'votos' | 'classe' // Matches the column's own `id` and its `?level=` filter param; the SORT key @@ -214,8 +213,7 @@ const CLASS_COLUMN_DESCRIPTION = * on props, so there is nothing to recompute per render. */ export const municipalityColumnDescriptions: Record = { - name: 'Nome do município e prioridade da campanha.', - region: 'Território de Identidade (Bahia) a que o município pertence.', + name: 'Nome do município, Território de Identidade e prioridade da campanha.', votos: formatMunicipalityConcentrationHint(), classe: CLASS_COLUMN_DESCRIPTION, level: campaignConceptOneLiner('nivel-de-envolvimento'), @@ -224,7 +222,8 @@ export const municipalityColumnDescriptions: Record */ export const municipalityColumnLabels: Record = { name: municipalityListSortLabels.name, - region: municipalityListSortLabels.region, /** Header is the bare year, under the "2022" group of the table. */ votos: 'Votação 2022', - classe: municipalityListSortLabels.classe, + expectedVotes: 'Estimativa 2026', level: municipalityListSortLabels.nivel, - advisors: municipalityListSortLabels.coverage, - /** B155 — the column has no sort/filter; the label stands alone in the picker. */ - leaderships: 'Lideranças', - /** B157 — nova coluna entre Assessores e Tendência (grupo "rede"). */ - stateDeputies: 'Dobradinhas', + classe: municipalityListSortLabels.classe, + advisors: 'Assessor', trend: municipalityListSortLabels.trend, - expectedVotes: municipalityListSortLabels.expectedVotes, - /** The column shows the signal; `frescor` sorts by how old it is. */ - lastSignal: 'Último sinal', + leaderships: 'Liderança', + stateDeputies: 'Dobradinha', goalCoverage: municipalityListSortLabels.deficit, + /** The column shows the signal; `frescor` sorts by how old it is. */ + lastSignal: 'Sinal', lastUpdateAt: municipalityListSortLabels.lastUpdateAt, } diff --git a/tests/e2e/campaignColumnPicker.e2e.spec.ts b/tests/e2e/campaignColumnPicker.e2e.spec.ts index 1d78486d..a4ddabe6 100644 --- a/tests/e2e/campaignColumnPicker.e2e.spec.ts +++ b/tests/e2e/campaignColumnPicker.e2e.spec.ts @@ -25,6 +25,9 @@ test.describe('Seletor de colunas', () => { // Crosses two heavy list routes; `/campanha/liderancas` only finishes // compiling in dev when an authenticated request reaches its tree. test.slow() + // B158 switches to cards below 48rem of content width. Give this picker + // regression enough content room to observe table headers directly. + await page.setViewportSize({ width: 1800, height: 900 }) const { fixtures } = campaign const coordinator = await fixtures.createCampaignUser('coordinator', { @@ -36,20 +39,33 @@ test.describe('Seletor de colunas', () => { await campaign.login(page, email, password) await page.goto('/campanha/municipios') - const regionHeader = page.getByRole('columnheader', { name: /Território/ }) - await expect(regionHeader).toBeVisible() + const votesHeader = page.getByRole('columnheader', { name: /2022/ }) + await expect(votesHeader).toBeVisible() const picker = page.getByRole('button', { name: /Mostrar ou ocultar colunas/ }) await picker.click() - await page.getByRole('checkbox', { name: 'Território' }).click() + // A fresh profile receives B158's two defaults. Restoring all records the + // deliberate empty preference instead of reverting to those defaults. + await page.getByRole('button', { name: 'Restaurar todas' }).click() + await page.keyboard.press('Escape') + await expect + .poll( + async () => + (await page.context().cookies()).find((cookie) => cookie.name === 'campaign_columns') + ?.value, + ) + .toContain('municipios:__none__') + + await picker.click() + await page.getByRole('checkbox', { name: 'Votação 2022' }).click() // Closing flushes the batched cookie write and refreshes the RSC. await page.keyboard.press('Escape') - await expect(regionHeader).toHaveCount(0, REFRESH) + await expect(votesHeader).toHaveCount(0, REFRESH) await expect(page.getByRole('button', { name: /1 oculta/ })).toBeVisible() await page.reload() - await expect(page.getByRole('columnheader', { name: /Território/ })).toHaveCount(0, REFRESH) + await expect(page.getByRole('columnheader', { name: /2022/ })).toHaveCount(0, REFRESH) // The cookie carries one key per list: another list is untouched. The // territories list is the honest neighbour here — its 27 rows come from @@ -63,7 +79,16 @@ test.describe('Seletor de colunas', () => { await page.getByRole('button', { name: 'Restaurar todas' }).click() await page.keyboard.press('Escape') - await expect(page.getByRole('columnheader', { name: /Território/ })).toBeVisible(REFRESH) + await expect(page.getByRole('columnheader', { name: /2022/ })).toBeVisible(REFRESH) + await page.reload() + await expect(page.getByRole('columnheader', { name: /2022/ })).toBeVisible(REFRESH) + await expect + .poll( + async () => + (await page.context().cookies()).find((cookie) => cookie.name === 'campaign_columns') + ?.value, + ) + .toContain('municipios:__none__') }) test('lists the mandatory column without letting it be unchecked', async ({ campaign, page }) => { diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index 037a49ca..5caa9d38 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -1,3 +1,5 @@ +import type { Page } from '@playwright/test' + import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '../../src/lib/campaignConsentKeys.js' import { ensureLeasedConsent, @@ -15,6 +17,39 @@ import { collectActionBoundingBoxes, } from './helpers/actionGridGeometry.js' +const showAllMunicipalityColumns = (page: Page, url: string) => + page.context().addCookies([ + { + name: 'campaign_columns', + value: encodeURIComponent('municipios:__none__'), + url, + }, + ]) + +const municipalityContainer = (page: Page) => + page.locator('[data-container="municipality-list"]').last() + +const visibleMunicipalityButton = (page: Page, name: string) => + municipalityContainer(page).getByRole('button', { name }).filter({ visible: true }) + +const ensureWideMunicipalityList = async (page: Page) => { + // The resizable panel hydrates independently from the RSC list and can + // change the container stage after a cell first becomes visible. These + // desktop cell journeys wait for the widest table stage; B158 exercises the + // rail transition itself in its dedicated spec. + await page.waitForLoadState('networkidle') + const container = municipalityContainer(page) + const width = await container.evaluate((element) => element.clientWidth) + const close = page.getByRole('button', { name: 'Fechar', exact: true }).filter({ visible: true }) + // A collapsed Panel can leave its overflowing close button technically + // visible. Only click when the list itself proves the chat still consumes + // space; otherwise the click is a no-op against an already collapsed rail. + if (width < 78 * 16 && (await close.isVisible())) await close.click() + await expect + .poll(() => container.evaluate((element) => element.clientWidth)) + .toBeGreaterThanOrEqual(78 * 16) +} + /** * Core municipality-model journeys per role: coordinator strategy editing, advisor * scoping, staff declare/estimate privacy boundary, leader lockdown, and staff-only @@ -26,6 +61,14 @@ import { */ test.describe('Municípios — jornadas por papel', () => { + test.beforeEach(async ({ page }) => { + // B158 keys the table stages to content width and gives fresh profiles a + // compact default. These pre-B158 journeys exercise specific desktop + // cells, so make that prerequisite explicit instead of finding hidden + // copies in the always-mounted mobile-card tree. + await page.setViewportSize({ width: 2400, height: 1000 }) + }) + test('coordinator opens the municipalities list, edits strategy and assigns an advisor', async ({ campaign, page, @@ -41,6 +84,7 @@ test.describe('Municípios — jornadas por papel', () => { const municipality = await fixtures.claimMunicipality() await campaign.login(page, coordinator.email!, password) + await showAllMunicipalityColumns(page, campaign.baseURL) await page.goto(`${campaign.baseURL}/campanha/municipios`) await expect(campaignPageChrome(page, 'Municípios')).toBeVisible() @@ -96,16 +140,23 @@ test.describe('Municípios — jornadas por papel', () => { const municipality = await fixtures.claimMunicipality() await campaign.login(page, coordinator.email!, password) + await showAllMunicipalityColumns(page, campaign.baseURL) await page.goto( `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`, ) await expect(campaignPageChrome(page, 'Municípios')).toBeVisible() + await ensureWideMunicipalityList(page) // Name includes the município — a substring search can return neighbors // (strict-mode flake when two "Editar assessores …" buttons match). - const advisorsTrigger = page.getByRole('button', { - name: `Editar assessores em ${municipality.name}`, - }) + // A concurrent RSC navigation may briefly retain the previous segment. + // Scope the editor to the appended, active municipality container so a + // hidden copy cannot absorb Playwright's click retries until timeout. + const advisorsTrigger = visibleMunicipalityButton( + page, + `Editar assessores em ${municipality.name}`, + ) + await expect(advisorsTrigger).toBeVisible() await advisorsTrigger.click() const advisorsPopover = page.locator('[data-slot="popover-content"]') @@ -117,16 +168,12 @@ test.describe('Municípios — jornadas por papel', () => { // No "Salvar" button in this popover: selecting the option auto-saves the // delta and renders it as a removable chip immediately. const chip = advisorsPopover.getByRole('button', { name: `Remover ${advisor.name}` }) - // Click the option itself: the inner text generic does not always trigger - // the combobox selection (measured — the popover stayed on "selected: você"). - // Retry loop for the pre-hydration flake class (same as - // `checkRadixWhenHydrated`): a click before React attaches is a silent - // no-op, so the probe retries until the chip sticks. - await expect(async () => { - await search.fill(searchNamePart) - await option.click({ timeout: 1_000 }) - await expect(chip).toBeVisible({ timeout: 4_000 }) - }).toPass({ timeout: 20_000 }) + // Opening this client-only popover already proves hydration. Wait for the + // write itself: the chip and trigger are optimistic and do not prove the + // assignment reached the database before the reload below. + await search.fill(searchNamePart) + await Promise.all([expectPostResponse(page, '/campanha/municipios/advisors'), option.click()]) + await expect(chip).toBeVisible() // The popover stays open after the write (auto-save, not submit+close). await expect(advisorsPopover).toBeVisible() @@ -143,11 +190,11 @@ test.describe('Municípios — jornadas por papel', () => { // state, now holds the assignment. await page.reload() await expect(campaignPageChrome(page, 'Municípios')).toBeVisible() - await page - .getByRole('button', { - name: `Editar assessores em ${municipality.name} — ${advisor.name}`, - }) - .click() + await ensureWideMunicipalityList(page) + await visibleMunicipalityButton( + page, + `Editar assessores em ${municipality.name} — ${advisor.name}`, + ).click() await expect( page.locator('[data-slot="popover-content"]').getByRole('button', { name: `Remover ${advisor.name}`, @@ -170,14 +217,17 @@ test.describe('Municípios — jornadas por papel', () => { const newAdvisorName = fixtures.value('Novo Assessor') await campaign.login(page, coordinator.email!, password) + await showAllMunicipalityColumns(page, campaign.baseURL) await page.goto( `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`, ) await expect(campaignPageChrome(page, 'Municípios')).toBeVisible() + await ensureWideMunicipalityList(page) - const advisorsTrigger = page.getByRole('button', { - name: `Editar assessores em ${municipality.name}`, - }) + const advisorsTrigger = visibleMunicipalityButton( + page, + `Editar assessores em ${municipality.name}`, + ) await advisorsTrigger.click() const advisorsPopover = page.locator('[data-slot="popover-content"]') await expect(advisorsPopover).toBeVisible() @@ -213,11 +263,11 @@ test.describe('Municípios — jornadas por papel', () => { // and the assignment, not just the local optimistic state. await page.reload() await expect(campaignPageChrome(page, 'Municípios')).toBeVisible() - await page - .getByRole('button', { - name: `Editar assessores em ${municipality.name} — ${newAdvisorName}`, - }) - .click() + await ensureWideMunicipalityList(page) + await visibleMunicipalityButton( + page, + `Editar assessores em ${municipality.name} — ${newAdvisorName}`, + ).click() await expect( page.locator('[data-slot="popover-content"]').getByRole('button', { name: `Remover ${newAdvisorName}`, @@ -241,14 +291,17 @@ test.describe('Municípios — jornadas por papel', () => { const party = 'PCdoB' await campaign.login(page, coordinator.email!, password) + await showAllMunicipalityColumns(page, campaign.baseURL) await page.goto( `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`, ) await expect(campaignPageChrome(page, 'Municípios')).toBeVisible() + await ensureWideMunicipalityList(page) - const deputiesTrigger = page.getByRole('button', { - name: `Editar dobradinhas em ${municipality.name}`, - }) + const deputiesTrigger = visibleMunicipalityButton( + page, + `Editar dobradinhas em ${municipality.name}`, + ) await deputiesTrigger.click() const deputiesPopover = page.locator('[data-slot="popover-content"]') await expect(deputiesPopover).toBeVisible() @@ -286,7 +339,8 @@ test.describe('Municípios — jornadas por papel', () => { // the assignment, not just the local optimistic state. await page.reload() await expect(campaignPageChrome(page, 'Municípios')).toBeVisible() - await page.getByRole('button', { name: `Editar dobradinhas em ${municipality.name}` }).click() + await ensureWideMunicipalityList(page) + await visibleMunicipalityButton(page, `Editar dobradinhas em ${municipality.name}`).click() await expect( page.locator('[data-slot="popover-content"]').getByRole('button', { name: `Remover ${deputyName} (${party})`, @@ -307,6 +361,7 @@ test.describe('Municípios — jornadas por papel', () => { const note = fixtures.value('Vereador confirmou apoio local') await campaign.login(page, coordinator.email!, password) + await showAllMunicipalityColumns(page, campaign.baseURL) await page.goto( `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`, ) @@ -349,18 +404,22 @@ test.describe('Municípios — jornadas por papel', () => { const municipality = await fixtures.claimMunicipality() await campaign.login(page, coordinator.email!, password) + await showAllMunicipalityColumns(page, campaign.baseURL) await page.goto( `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`, ) await expect(campaignPageChrome(page, 'Municípios')).toBeVisible() + await ensureWideMunicipalityList(page) // Anchored: a bare template regex on a municipality name collides with // prefix-shared names (Conde/Condeúba — 23/435 measured in the catalog), // and `exact` no longer matches because the accessible name appends the // current signal state ("— Sem sinal"). - const signalTrigger = page.getByRole('button', { - name: new RegExp(`^Registrar sinal em ${municipality.name} —`), - }) + const signalTrigger = municipalityContainer(page) + .getByRole('button', { + name: new RegExp(`^Registrar sinal em ${municipality.name} —`), + }) + .filter({ visible: true }) await expect(signalTrigger).toBeVisible() await signalTrigger.click() @@ -370,9 +429,13 @@ test.describe('Municípios — jornadas por papel', () => { .getByRole('textbox', { name: 'Texto' }) .fill('Liderança local reportou visita adversária na feira.') await signalForm.getByLabel('Tipo do sinal').selectOption('visita_adversario') - await signalForm.getByRole('button', { name: 'Registrar sinal', exact: true }).click() + await Promise.all([ + expectPostResponse(page, '/campanha/municipios'), + signalForm.getByRole('button', { name: 'Registrar sinal', exact: true }).click(), + ]) - await expect(page.getByText('Sinal registrado.', { exact: true })).toBeVisible() + // The toast is intentionally transient; the persisted freshness label is + // the stable success contract and must arrive after the server response. await expect( page.getByRole('button', { name: `Registrar sinal em ${municipality.name} — hoje`, @@ -433,6 +496,7 @@ test.describe('Municípios — jornadas por papel', () => { // Advisor scope: only the administered municipality shows up. await campaign.login(page, advisor.email!, password) + await showAllMunicipalityColumns(page, campaign.baseURL) await page.goto(`${campaign.baseURL}/campanha/municipios`) await expect( page.getByRole('link', { name: administered.name, exact: true }).first(), diff --git a/tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts b/tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts new file mode 100644 index 00000000..07090f97 --- /dev/null +++ b/tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts @@ -0,0 +1,248 @@ +import type { Locator, Page } from '@playwright/test' + +import { expect, test } from './fixtures/campaignE2EFixtures.js' + +const REM_IN_PIXELS = 16 +const municipalityColumnsCookie = encodeURIComponent('municipios:__none__') + +// During a concurrent RSC navigation Next may briefly retain the previous +// segment beside the incoming one. The current segment is appended last. +const municipalityContainer = (page: Page) => + page.locator('[data-container="municipality-list"]').last() + +const setContainerWidth = async (container: Locator, width: number) => { + await container.evaluate((element, nextWidth) => { + const htmlElement = element as HTMLElement + htmlElement.style.width = `${nextWidth}px` + htmlElement.style.maxWidth = `${nextWidth}px` + }, width) + await expect.poll(() => container.evaluate((element) => element.clientWidth)).toBe(width) +} + +const visibleTriggerText = (trigger: Locator) => + trigger.evaluate((element) => (element as HTMLElement).innerText.trim()) + +const visibleHeaders = (container: Locator) => + container + .locator('thead th') + .evaluateAll((headers) => + headers + .filter((header) => getComputedStyle(header).display !== 'none') + .map((header) => (header.textContent ?? '').replaceAll(/\s+/g, ' ').trim()), + ) + +const expectNoHorizontalOverflow = async (container: Locator) => { + await expect + .poll(() => container.evaluate((element) => element.scrollWidth <= element.clientWidth)) + .toBe(true) +} + +type ResponsiveCampaignFixture = { + fixtures: { + createCampaignUser: ( + role: 'advisor' | 'coordinator', + input: { name: string }, + ) => Promise<{ email?: string | null; password: string }> + value: (prefix: string) => string + } + login: (page: Page, identifier: string, password: string) => Promise +} + +const loginWithAllColumns = async ( + page: Page, + campaign: ResponsiveCampaignFixture, + role: 'advisor' | 'coordinator' = 'coordinator', +) => { + const user = await campaign.fixtures.createCampaignUser(role, { + name: campaign.fixtures.value(`Responsivo ${role}`), + }) + await campaign.login(page, user.email!, user.password) + await page.context().addCookies([ + { + name: 'campaign_columns', + value: municipalityColumnsCookie, + url: new URL(page.url()).origin, + }, + ]) + await page.goto('/campanha/municipios') + await page.waitForLoadState('networkidle') + await expect(municipalityContainer(page)).toBeVisible() +} + +const expectedHeadersAt = (width: number) => { + if (width < 48 * REM_IN_PIXELS) return [] + + const headers = ['Município', '2022', '2026', 'Nível', 'Classe'] + if (width >= 54 * REM_IN_PIXELS) headers.push('Assessores') + headers.push('Tendência') + if (width >= 66 * REM_IN_PIXELS) headers.push('Liderança') + if (width >= 72 * REM_IN_PIXELS) headers.push('Dobradinha') + if (width >= 78 * REM_IN_PIXELS) headers.push('Cobertura') + headers.push('Sinal') + return headers +} + +test.describe('B158 — colunas responsivas por largura do conteúdo', () => { + test('crosses every container threshold without horizontal scrolling', async ({ + campaign, + page, + }) => { + test.slow() + + await loginWithAllColumns(page, campaign) + const container = municipalityContainer(page) + const cards = container.locator('[data-view="mobile-cards"]') + const table = container.getByRole('table') + const trendTrigger = table.getByRole('button', { name: /^Editar tendência política/ }).first() + const signalTrigger = table.getByRole('button', { name: /^Registrar sinal em/ }).first() + // Test around (and on) the three binary switches: cards↔table (48rem), + // trend text (60rem), signal text (84rem). Container queries use min-width. + const widths = [ + 48 * REM_IN_PIXELS - 1, + 48 * REM_IN_PIXELS, + 48 * REM_IN_PIXELS + 1, + 54 * REM_IN_PIXELS - 1, + 54 * REM_IN_PIXELS + 1, + 60 * REM_IN_PIXELS - 1, + 60 * REM_IN_PIXELS, + 60 * REM_IN_PIXELS + 1, + 66 * REM_IN_PIXELS - 1, + 66 * REM_IN_PIXELS + 1, + 72 * REM_IN_PIXELS - 1, + 72 * REM_IN_PIXELS + 1, + 78 * REM_IN_PIXELS - 1, + 78 * REM_IN_PIXELS + 1, + 84 * REM_IN_PIXELS - 1, + 84 * REM_IN_PIXELS, + 84 * REM_IN_PIXELS + 1, + ] + + for (const width of widths) { + await test.step(`${width}px`, async () => { + await setContainerWidth(container, width) + + if (width < 48 * REM_IN_PIXELS) { + await expect(cards).toBeVisible() + await expect(table).toBeHidden() + } else { + await expect(cards).toBeHidden() + await expect(table).toBeVisible() + expect(await visibleHeaders(container)).toEqual(expectedHeadersAt(width)) + expect(await visibleTriggerText(trendTrigger)).toBe( + width >= 60 * REM_IN_PIXELS ? 'Não registrada' : '', + ) + expect(await visibleTriggerText(signalTrigger)).toBe( + width >= 84 * REM_IN_PIXELS ? 'Sem sinal' : '', + ) + } + + await expectNoHorizontalOverflow(container) + }) + } + }) + + test('sidebar and Sollinha change the column stage without changing the viewport', async ({ + campaign, + page, + }) => { + test.slow() + + // At 1600px the real desktop rails straddle the responsive stages: the + // collapsed sidebar adds a column and the open chat removes one. A wider + // viewport can leave all optional columns visible in both states, which + // would only prove that the container shrank, not that its query reacted. + await page.setViewportSize({ width: 1600, height: 900 }) + await loginWithAllColumns(page, campaign) + const container = municipalityContainer(page) + const viewportBefore = page.viewportSize() + const widthWithSidebar = await container.evaluate((element) => element.clientWidth) + const headersWithSidebar = await visibleHeaders(container) + + await page.getByRole('button', { name: 'Abrir ou fechar menu da campanha' }).click() + await expect + .poll(() => container.evaluate((element) => element.clientWidth)) + .toBeGreaterThan(widthWithSidebar) + await expect + .poll(async () => (await visibleHeaders(container)).length) + .toBeGreaterThan(headersWithSidebar.length) + const widthAfterSidebarToggle = await container.evaluate((element) => element.clientWidth) + const initiallyOpenChatClose = page + .getByRole('button', { name: 'Fechar', exact: true }) + .filter({ visible: true }) + if ((await initiallyOpenChatClose.count()) > 0) { + await initiallyOpenChatClose.click() + await expect + .poll(() => container.evaluate((element) => element.clientWidth)) + .toBeGreaterThan(widthAfterSidebarToggle) + } + const widthWithoutSidebar = await container.evaluate((element) => element.clientWidth) + const headersWithoutSidebar = await visibleHeaders(container) + + await page + .getByRole('button', { name: 'Sollinha — Assistente virtual' }) + .filter({ visible: true }) + .click() + await expect + .poll(() => container.evaluate((element) => element.clientWidth)) + .toBeLessThan(widthWithoutSidebar) + await expect + .poll(async () => (await visibleHeaders(container)).length) + .toBeLessThan(headersWithoutSidebar.length) + const widthWithChat = await container.evaluate((element) => element.clientWidth) + const headersWithChat = await visibleHeaders(container) + await page + .getByRole('button', { name: 'Fechar', exact: true }) + .filter({ visible: true }) + .click() + await expect + .poll(() => container.evaluate((element) => element.clientWidth)) + .toBeGreaterThan(widthWithChat) + await expect + .poll(async () => (await visibleHeaders(container)).length) + .toBeGreaterThan(headersWithChat.length) + expect(page.viewportSize()).toEqual(viewportBefore) + await expectNoHorizontalOverflow(container) + }) + + test('keeps Dobradinha restricted even when an advisor has enough room', async ({ + campaign, + page, + }) => { + await loginWithAllColumns(page, campaign, 'advisor') + const container = municipalityContainer(page) + await setContainerWidth(container, 84 * REM_IN_PIXELS + 1) + + await expect(container.getByRole('columnheader', { name: /Assessores/ })).toBeVisible() + await expect(container.getByRole('columnheader', { name: 'Dobradinha' })).toHaveCount(0) + await expectNoHorizontalOverflow(container) + }) + + test('keeps the pinned name and 44px edit targets on an iPad viewport', async ({ + campaign, + page, + }) => { + await page.setViewportSize({ width: 834, height: 1194 }) + await loginWithAllColumns(page, campaign) + const container = municipalityContainer(page) + // Force the container past 48rem on the iPad viewport — the sidebar + // normally leaves too little content width for the table to appear. + await setContainerWidth(container, 48 * REM_IN_PIXELS + 1) + const table = container.getByRole('table') + await expect(table).toBeVisible() + + const stickyPosition = await table + .getByRole('columnheader', { name: /Município/ }) + .evaluate((element) => getComputedStyle(element).position) + expect(stickyPosition).toBe('sticky') + + for (const trigger of [ + table.getByRole('button', { name: /^Editar tendência política/ }).first(), + table.getByRole('button', { name: /^Registrar sinal em/ }).first(), + ]) { + const box = await trigger.boundingBox() + expect(box?.width).toBeGreaterThanOrEqual(44) + expect(box?.height).toBeGreaterThanOrEqual(44) + } + await expectNoHorizontalOverflow(container) + }) +}) diff --git a/tests/unit/campaignColumnPicker.unit.spec.ts b/tests/unit/campaignColumnPicker.unit.spec.ts index 88eb4b13..02192b43 100644 --- a/tests/unit/campaignColumnPicker.unit.spec.ts +++ b/tests/unit/campaignColumnPicker.unit.spec.ts @@ -22,7 +22,7 @@ let cookieJar = '' const COLUMNS = [ { id: 'name', label: 'Município', mandatory: true }, - { id: 'region', label: 'Território' }, + { id: 'goalCoverage', label: 'Cobertura' }, { id: 'trend', label: 'Tendência' }, ] @@ -73,7 +73,7 @@ describe('CampaignColumnPicker', () => { render(picker([])) openMenu() - fireEvent.click(screen.getByRole('checkbox', { name: 'Território' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' })) // Reading the next label takes longer than any debounce worth having: // a second between checkboxes must still be one refresh, not two. await vi.advanceTimersByTimeAsync(1_000) @@ -84,7 +84,7 @@ describe('CampaignColumnPicker', () => { closeMenu() expect(refresh).toHaveBeenCalledTimes(1) - expect(cookieJar).toContain('campaign_columns=municipios:region~trend') + expect(cookieJar).toContain('campaign_columns=municipios:goalCoverage~trend') } finally { vi.useRealTimers() } @@ -96,14 +96,14 @@ describe('CampaignColumnPicker', () => { try { render(picker([])) openMenu() - fireEvent.click(screen.getByRole('checkbox', { name: 'Território' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' })) await vi.advanceTimersByTimeAsync(3_000) // The idle timer is durability, not the commit: a slow reader who takes // more than the interval between checkboxes must not pay a route render // per column, which is the failure the 400 ms debounce was rejected for. - expect(cookieJar).toContain('campaign_columns=municipios:region') + expect(cookieJar).toContain('campaign_columns=municipios:goalCoverage') expect(refresh).not.toHaveBeenCalled() closeMenu() @@ -114,11 +114,11 @@ describe('CampaignColumnPicker', () => { }) it('does not refresh a session that ends where it started', () => { - render(picker(['region'])) + render(picker(['goalCoverage'])) openMenu() - fireEvent.click(screen.getByRole('checkbox', { name: 'Território' })) - fireEvent.click(screen.getByRole('checkbox', { name: 'Território' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' })) closeMenu() expect(refresh).not.toHaveBeenCalled() @@ -130,10 +130,21 @@ describe('CampaignColumnPicker', () => { // Radix reports no close event here, so without the unmount flush this // choice would be lost to a sidebar navigation with no feedback at all. - fireEvent.click(screen.getByRole('checkbox', { name: 'Território' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' })) unmount() - expect(cookieJar).toContain('campaign_columns=municipios:region') + expect(cookieJar).toContain('campaign_columns=municipios:goalCoverage') + }) + + it('serializes restoring every municipality column as an explicit empty choice', () => { + render(picker(['trend'])) + openMenu() + + fireEvent.click(screen.getByRole('button', { name: 'Restaurar todas' })) + closeMenu() + + expect(cookieJar).toContain('campaign_columns=municipios:__none__') + expect(refresh).toHaveBeenCalledTimes(1) }) it('never lets the mandatory column be unchecked', () => { @@ -150,7 +161,7 @@ describe('CampaignColumnPicker', () => { const { rerender } = render(picker([])) openMenu() - fireEvent.click(screen.getByRole('checkbox', { name: 'Território' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'Cobertura' })) closeMenu() await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)) @@ -158,8 +169,8 @@ describe('CampaignColumnPicker', () => { openMenu() fireEvent.click(screen.getByRole('checkbox', { name: 'Tendência' })) // … and the RSC payload of the FIRST one lands inside it. Adopting it here - // would uncheck nothing and re-commit `region` alone, dropping `trend`. - rerender(picker(['region'])) + // would uncheck nothing and re-commit `goalCoverage` alone, dropping `trend`. + rerender(picker(['goalCoverage'])) expect(screen.getByRole('checkbox', { name: 'Tendência' }).getAttribute('aria-checked')).toBe( 'false', @@ -167,19 +178,19 @@ describe('CampaignColumnPicker', () => { closeMenu() await waitFor(() => expect(refresh).toHaveBeenCalledTimes(2)) - expect(cookieJar).toContain('campaign_columns=municipios:region~trend') + expect(cookieJar).toContain('campaign_columns=municipios:goalCoverage~trend') }) it('adopts the server payload once nothing is pending', async () => { const { rerender } = render(picker([])) - rerender(picker(['region'])) + rerender(picker(['goalCoverage'])) openMenu() await waitFor(() => - expect( - screen.getByRole('checkbox', { name: 'Território' }).getAttribute('aria-checked'), - ).toBe('false'), + expect(screen.getByRole('checkbox', { name: 'Cobertura' }).getAttribute('aria-checked')).toBe( + 'false', + ), ) expect(refresh).not.toHaveBeenCalled() }) diff --git a/tests/unit/campaignColumnVisibility.unit.spec.ts b/tests/unit/campaignColumnVisibility.unit.spec.ts index a465b949..69ae23c8 100644 --- a/tests/unit/campaignColumnVisibility.unit.spec.ts +++ b/tests/unit/campaignColumnVisibility.unit.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { parseCampaignHiddenColumns, + resolveCampaignColumnVisibility, resolveVisibleColumns, serializeCampaignHiddenColumns, toggleHiddenColumn, @@ -18,9 +19,13 @@ describe('parseCampaignHiddenColumns', () => { }) }) - it('shows the full table when the cookie is absent, empty or malformed', () => { + it('keeps an absent list distinct from an explicitly empty list', () => { expect(parseCampaignHiddenColumns(undefined)).toEqual({}) expect(parseCampaignHiddenColumns('')).toEqual({}) + expect(parseCampaignHiddenColumns('municipios:__none__')).toEqual({ municipios: [] }) + }) + + it('ignores malformed entries instead of guessing', () => { expect(parseCampaignHiddenColumns('garbage')).toEqual({}) expect(parseCampaignHiddenColumns(':kind')).toEqual({}) expect(parseCampaignHiddenColumns('municipios:')).toEqual({}) @@ -42,12 +47,13 @@ describe('parseCampaignHiddenColumns', () => { }) describe('serializeCampaignHiddenColumns', () => { - it('round-trips through parse and omits lists with nothing hidden', () => { - const value = { municipios: ['kind', 'trend'], demandas: ['requester'] } + it('round-trips hidden columns and explicit empty lists', () => { + const value = { municipios: ['kind', 'trend'], demandas: [] } const serialized = serializeCampaignHiddenColumns(value) expect(parseCampaignHiddenColumns(serialized)).toEqual(value) - expect(serializeCampaignHiddenColumns({ municipios: [] })).toBe('') + expect(serializeCampaignHiddenColumns({ municipios: [] })).toBe('municipios:__none__') + expect(serializeCampaignHiddenColumns({})).toBe('') }) it('never emits a character that would break the cookie value', () => { @@ -57,6 +63,26 @@ describe('serializeCampaignHiddenColumns', () => { }) }) +describe('resolveCampaignColumnVisibility', () => { + it('applies compact defaults only when municipios is absent', () => { + expect(resolveCampaignColumnVisibility('municipios', {})).toEqual({ + listId: 'municipios', + hiddenColumnIds: ['goalCoverage', 'lastSignal'], + }) + expect(resolveCampaignColumnVisibility('municipios', { municipios: [] })).toEqual({ + listId: 'municipios', + hiddenColumnIds: [], + }) + }) + + it('keeps the existing empty default for every other list', () => { + expect(resolveCampaignColumnVisibility('liderancas', {})).toEqual({ + listId: 'liderancas', + hiddenColumnIds: [], + }) + }) +}) + describe('toggleHiddenColumn', () => { it('hides, shows and never duplicates an id', () => { const hidden = toggleHiddenColumn([], 'kind', false) diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts index 363cea1a..f3b3b9a3 100644 --- a/tests/unit/campaignComponents.unit.spec.ts +++ b/tests/unit/campaignComponents.unit.spec.ts @@ -8,7 +8,10 @@ import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge' import { formatAdvisorNamesTooltip } from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack' -import { MunicipalityList } from '@/components/campaign/municipality/MunicipalityList' +import { + MunicipalityList, + municipalityListPickerColumns, +} from '@/components/campaign/municipality/MunicipalityList' import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl' import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl' import { TseZoneBadge } from '@/components/campaign/municipality/TseZoneBadge' @@ -62,6 +65,34 @@ const municipalityListDefaultProps = { state: { page: 1 }, } +const responsiveMunicipality = stub({ + id: 1, + name: 'São Francisco do Conde com nome extenso', + slug: 'sao-francisco-do-conde', + kind: 'municipio', + city: 'São Francisco do Conde', + region: 'Metropolitano de Salvador', + ibgeCode: '2929206', + zoneNumber: null, + advisorIDs: [], + leadershipIDs: [], + stateDeputyIDs: [], + priority: 'normal', + lastUpdateAt: null, + lastSignalAt: null, + expectedVotes: toVoteEstimateScenarioViewModel(null), + politicalTrendStatus: null, + politicalTrendNote: null, + engagementLevel: null, + levelNote: null, + levelChangedAt: null, + pledges: createEmptyMunicipalityPledgeAggregate(), + votePosition2022: null, + territorialClass: 'sem_base', + territorialClassFactors: [], + goalCoverageByScenario: createEmptyGoalCoverageByScenario(), +}) + const mockAppRouter = stub[0]['value']>({ push: () => {}, replace: () => {}, @@ -279,15 +310,24 @@ describe('campaign visual foundation', () => { // B155: the Lideranças column chips the linked leaderships by name. expect(html).toContain('Lideranças') expect(html).toContain('Maria de Jesus') - // B22: every one of the 11 staff columns carries a header explanation — + // B22: every one of the 10 visible-by-default staff columns carries a header explanation — // pinned inside `