diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 35ec81f0..f2594ed8 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -22,7 +22,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-09):** **C94** (Issue #438) — barra da agenda consolidada: os 3 seletores (Município/Tag/Deputado presente) viraram **um combobox omnibox** (`activityAgendaOmnibox.ts` + chassis B127) com chips removíveis/“Limpar” e sugestões agrupadas; desktop toolbar `[combobox][carrinho giro][+ nova]` (ícones; mobile sem botão de criação no corpo — tudo no FAB); “Link de import” **saiu do card de filtros** para o **header do app** (`CampaignDesktopHeader`, **agenda-contextual**, ordem `[link][sino][IA]` do gate) via registro chaveado `SetCampaignHeaderAction` no `CampaignPageChromeContext` (slot compartilhado com C95), e virou ação de drawer no FAB mobile (`CampaignQuickAction.onAction` + `openCalendarFeed` no `CampaignQuickActionContext`) abrindo o **mesmo diálogo como sheet** responsivo (`CalendarFeedDialog` Dialog/Drawer; sem recorte mostra aviso “Aplique filtros…”, C93 cobre gerar sem filtro). O overlay do FAB fecha ao disparar ação de dialog. Sem migration/access/Consent. Intenção [`c94-agenda-combobox-header-acoes-rapidas.md`](docs/plans/c94-agenda-combobox-header-acoes-rapidas.md); impl [`c94-agenda-combobox-header-acoes-rapidas-impl.md`](docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md). -(feat(C94): toolbar omnibox + e2e agenda hardening + changelog) +**Recently resolved (2026-08-08):** **B172** (Issue #446) — na lista staff `/campanha/municipios`, abaixo da faixa C2 (< 60rem do container) a coluna **Tendência deixa de colapsar para ícone no próprio lugar** (inverte a decisão B158 "container query nunca duplica" **só para Tendência**): a coluna inteira some (`hidden @min-60:table-cell` no th+td) e o **mesmo** editor de tendência (status+justificativa, autosave) passa a morar como ícone compacto na coluna **Ações**, ao lado do de atualização; acima de 60rem volta o badge completo e Ações é gated fechado (`@min-60:hidden`) quando nada está oculto manualmente. `Ações` tornou-se **sempre** montada para staff (antes só por ocultação manual) e pinada quando `trend`/`lastSignal` está oculto no B17. Uma instância **visível** do editor por linha por faixa (dual-render CSS-gated, sem JS/resize — a leitura "uma instância" do B158 virou "uma visível por faixa"). Atualização/Sinal fica de fora (opção A). Side effect documentado: sort/filtro de tendência via header some abaixo de 60rem (seguem no omnibox). Sem migration/dados/Consent. **Bug de produção corrigido no caminho (React 19.2.4 flight):** o `className` novo na coluna destravou um bug do serializer do RSC — com `votos` oculta o thead virou uma tarefa de ~4,7 KB (acima do `MAX_ROW_SIZE=3200`) e o span do cabeçalho `Dobradinha` (com `description`) foi deferido para chunk posterior; no SSR o `CampaignHoverTooltip` recebia o placeholder não-resolvido como `children` → `cloneElement(undefined)` → React #130 derrubando o painel (e2e `campaignColumnPicker` vermelho 3/3 em prod, verde em dev). Correção: `CampaignTableHead` passou a enviar o rótulo como prop de dados (`explanationLabel`) em vez de elemento `children` — string nunca é deferida; o tooltip reconstrói o trigger. Sem mudança visual; os demais call sites do tooltip inalterados. Intenção [`tendencia-icone-coluna-acoes.md`](docs/plans/tendencia-icone-coluna-acoes.md); impl [`tendencia-icone-coluna-acoes-impl.md`](docs/plans/tendencia-icone-coluna-acoes-impl.md). + **Recently resolved (2026-08-08):** **`pnpm worktree plan [bag] [--go] [--no-migrate]`** — worktree de **planejamento** para rodar a skill `/plan-issue` sem ocupar o main: **cada invocação cria UM DIFERENTE**, para sessões de planejamento paralelas — com `bag`, branch `plans/plan-issue-` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag`, o próximo sequencial `plans/plan-issue-` livre (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`). Prefixo minúsculo `plans/…` nunca colide com um `next` posterior em branch nem em slot (slot do plano = hash do branch, registrado via marcador e bumpado pelos vivos). Mesmo provisionamento isolado do `next` (porta `3100+slot`, bancos `teqo_wt`/`teqo_wt_test`, migrations; sem Docker degrada ao compartilhado). `kill` cobre; comment das envs distingue `gerado por pnpm worktree plan`. Sem migration. Naming unit-testada. **Recently resolved (2026-08-08):** **B168** (Issue #422) — desfeito o **encadeamento automático** das ações rápidas do wizard (B98 era o precedente que este item inverte; evidência de campo: o coordenador rejeitou a tela avançando sozinha). Ao salvar **qualquer** ação de ajuste (votos, tendência, atualização, liderança), o usuário volta à origem (`from` allowlist B110 mantido; Início sem `from`) com toast — nunca avança para outro wizard. Removidos a fila/pipeline `wizardActionChain.ts` (fundido `wizardReturnHref` + `wizardPreviousHref` só com passos internos em `campaignActionRoutes.ts`), o param `entry`, os skip resolvers/`Pular` (`WizardSkipTrailing`/`WizardTrendSkipTrailing` deletados, chrome mobile sempre “X”) e o prefill de nota sem produtor (`notePrefill`/`voteFrom`/`voteTo`). Tendência: pós-save o RSC refresha com status já aplicado e o **client** é o único dono da navegação (toast + retorno) — o redirect servidor engolia o toast (B97 corrigido no caminho). Liderança: botão flutuante pós-save passou de “Continuar” (avançava) para **“Concluir”** (retorna à origem). Sem migration. Intenção [`desfazer-encadeamento-acoes-wizard.md`](plans/desfazer-encadeamento-acoes-wizard.md); impl [`desfazer-encadeamento-acoes-wizard-impl.md`](plans/desfazer-encadeamento-acoes-wizard-impl.md). diff --git a/docs/plans/tendencia-icone-coluna-acoes-impl.md b/docs/plans/tendencia-icone-coluna-acoes-impl.md new file mode 100644 index 00000000..6d3fbf82 --- /dev/null +++ b/docs/plans/tendencia-icone-coluna-acoes-impl.md @@ -0,0 +1,84 @@ +# Impl: B172 — Lista de municípios: ao colapsar, coluna de Tendência desaparece e o ícone vai para Ações + +Status: aprovado +Atualizado em: 2026-08-08 +Issue: #446 +Intenção: docs/plans/tendencia-icone-coluna-acoes.md +Appetite restante: ~0,5–1 dia eng (herdado); uma superfície tocada (`MunicipalityList.tsx` + testes) + +## Leitura da intenção + +- **Outcome:** abaixo de 60rem (ponto em que o badge de Tendência colapsaria), a coluna `Tendência` não é renderizada e o editor de tendência aparece como ícone compacto na coluna `Ações`, ao lado do ícone de atualização. Acima, a coluna mostra o badge completo e `Ações` não ganha cópia. Uma instância visível do editor por linha em qualquer largura. Preferência do picker B17 preservada. +- **O que NÃO negociar:** um editor por linha por faixa de largura (sem double autosave/popover); ocultação manual do B17 continua valendo quando há espaço; sem JS de resize — container queries como B158; `Atualização` fora de escopo (questão 1 resolvida como **opção A** — só `Tendência` migra; a coluna `Sinal` continua adaptativa no próprio lugar). +- **O que reavaliar:** a hipótese da intenção apontava seam `triggerPresentation` no controle. A inspeção mostra que isso não basta: o trigger não pode ser **movido entre ``s** com CSS — display só alterna no lugar. A migração exige uma segunda instância do controle (CSS-gated), o que muda a leitura de "uma instância" (vira uma por faixa, não uma no DOM). + +## Abordagem recomendada + +```mermaid +flowchart LR + A[coluna trend] -->|class hidden @min-60:table-cell| B[escondida < 60rem] + A -->|>= 60rem| C[badge completo] + D[coluna actions] -->|class @min-60:hidden se nenhuma ocultação manual| E[< 60rem: ícone trend migrado] + D -->|trendIsHidden/signalIsHidden -> sempre visível| F[ícones pinados] +``` + +**Opções consideradas:** A) dual-render do controle + gating por container query na coluna; B) drop da coluna `trend` e trigger só em `Ações`; C) mover o controle único via JS/portal. +**Recomendação: A** — é a única que mantém o aceite completo (badge na coluna em largura + ícone em `Ações` em estreito) com CSS puro, zero JS de resize, coerente com a mecânica B158. +**Rejeitadas:** B porque em tela larga o usuário perde o readout de tendência na linha (aceite exige badge na coluna); C porque relocar uma instância única entre células de tabela exige `ResizeObserver`/mutation das `` — o anti-goal da intenção (sem JS de resize) e a arquitetura "server decide, CSS aparenta" do B158. + +### Componentes / mudanças + +- **`municipalityListColumns`** (`src/components/campaign/municipality/MunicipalityList.tsx`): + - `responsiveColumnClassName` ganha `trend: 'hidden @min-[60rem]/municipality-list:table-cell'`; aplicado no `head` (via `MunicipalitySortableHead className`) e `cellClassName` da coluna `trend`. O renderer da célula mantém `renderTrendControl(municipality, 'adaptive')` **inalterado**: acima de 60rem o adaptive mostra o badge completo; a coluna some abaixo — zero mudança na fiação do controle. + - Coluna `actions`: deixa de ser condicionada a `trendIsHidden || signalIsHidden` e passa a **sempre** ser montada para staff (tem de ser — ela é a casa do fallback de largura mesmo com `Tendência` visível). `const actionsPinned = trendIsHidden || signalIsHidden`; `head`/`cellClassName` recebem `actionsPinned ? undefined : '@min-[60rem]/municipality-list:hidden'` (visível só abaixo de 60rem quando nada está oculto manualmente). + - Célula de `actions`: trecho de tendência vira + `trendIsHidden ? renderTrendControl(m, 'compact') : {renderTrendControl(m, 'compact')}` + (ícone pinado quando oculto; gated por container quando visível). Trecho de sinal **inalterado** (`signalIsHidden ? … : null`, opção A). + - A união local `MunicipalityTableColumnId` já inclui `'actions'` — nada a mudar. +- **`MunicipalityListTrendControl.tsx`:** sem mudança. O seam `'compact'`/`'adaptive'` já existe (51963c... — usados hoje para `actions` e coluna). +- **Migration:** nenhuma. **Access/Consent:** nenhum. **UI:** Impeccable B — encaixe em tela existente, sem shape/craft além da movimentação do trigger; validação visual no browser (Playwright + snapshot perceptual do contrato abaixo/em/acima). + +### Matriz de largura × visibilidade (contrato do e2e) + +| Container | trends visível no picker | trend oculto | +| ---------- | ----------------------------------------------------------------------------------------- | -------------------------------------- | +| `< 60rem` | coluna `Tendência` some · `Ações` visível com ícone trend (+ sinal se oculto manualmente) | `Ações` visível com ícone trend pinado | +| `>= 60rem` | coluna `Tendência` com badge completo · `Ações` `display:none` (não-frame) | `Ações` visível com ícone trend pinado | + +Quando `Sinal` está oculto manualmente, `Ações` é sempre visível (pinado) e o ícone de tendência, se a coluna estiver visível, fica gated por `@min-[60rem]:hidden` dentro dela. + +> **Side effect assumido (documentado no gate de review):** abaixo de 60rem o `` da coluna `Tendência` sai de cena (`display:none`), então o **sort por Tendência e o filtro de tendência via header** ficam inacessíveis nessa faixa (pre-B172 eles existiam como ícone no próprio lugar). O sort/filtro continua disponível pelo omnibox (mesmas seeds de `municipalityOmnibox.ts`); o trade-off é o objetivo do item (coluna sumir em vez de colapsar). Não é regressão: se a coluna não existe, suas affordances de header não existem. + +## Fases verificáveis + +1. **Server/render** — editar `MunicipalityList.tsx` (classes da coluna `trend` + lógica de `actions`); `pnpm gate:fast` (lint + typecheck + unit). +2. **Testes** — atualizar `tests/unit/campaignComponents.unit.spec.ts` (matriz `hasActions` e contagem de labels) e `tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts` (`expectedHeadersAt`); rodar unit. +3. **Browser/E2E** — `pnpm test:unit`; e2e dirigido `tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts` (dev server 3272 + `teqo_wt172_test`); `pnpm test:int` (sem impacto esperado, confirma verde); `pnpm check:cycles`, `knip`, `format:check`. + +## Rabbit holes / Não escopo (engenharia) + +- Reorganizar `Ações`/picker/outros controles (corte do item e da intenção). +- Aplicar a mesma regra a `Sinal`/`Atualização` (opção B da intenção — item sucessor). +- Mudar o breakpoint 60rem (contrato comportamental de B158 C2; calibrar só com evidência `scrollWidth`, como B158 explicitava). +- `CampaignTable`/shell genérico: não criar seam compartilhado — 1 call site, `cellClassName` local basta (depth check). + +## Riscos e mitigação + +- **Duas instâncias montadas por linha (coluna + `Ações`), uma visível por faixa:** custo de memória/estado de autosave 2× por linha em todas as larguras. Mitigação: tabela paginada e excluída a linha quando `trend` está oculto (a instância da coluna nem existe); aceita-se como o preço de CSS puro — e é a mesma dualidade que o adaptive (compact/full spans) B158 já monta. O teste bloqueia que as duas apareçam na mesma faixa (e2e de headers + primeiro trigger visível). +- **Regressão de contagem de labels nos unit:** os testes de "uma instância" precisam reapontar o contrato de `hidden: []` e `hidden: ['lastSignal']` (agora 2 labels de tendência no markup). Atualização explícita na fase 2. +- **e2e B158 `expectedHeadersAt`:** agora `Tendência` só acima de 60rem e `Ações` só abaixo — atualizar a tabela de expects. O teste de sidebar/Sollinha usa só o `.length` (sem nomes), e a aritmética de contagem atravessa 60rem com saldo 0 em ambos os cenários (antes: `Tendência` sempre; agora troca `Ações`↔`Tendência`), então não muda o comportamento monotônico dele. +- **GRID `Ações` vazia:** nunca há `Ações` vazia visível — toda configuração que a mostra tem ao menos um ícone (trend fallback ou pinado, ou sinal pinado). + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (única instância visível por faixa, B17 preservado, `Atualização` fora). +- [x] Invariantes AGENTS/engineering-standards (sem migration/Consent/URL; CSS-only; sem `window`/`ResizeObserver`). +- [x] Testes de domínio previstos: unit de markup (classes simétricas e contagem de triggers) + e2e de faixas de container (`expectedHeadersAt`), sem testar preset de viewport. + +## Bug de produção encontrado e corrigido (React 19.2.4 flight) + +O `className` novo na coluna `Tendência` (`MunicipalitySortableHead`) destravou um bug do **React 19.2.4** no serializer do RSC flight, que o e2e `campaignColumnPicker` pegou (3/3 vermelho em prod, verde em dev): + +- **Sintoma:** `Minified React error #130 — Element type is invalid: ... got: undefined`, derrubando o painel inteiro no SSR quando uma coluna era ocultada pelo picker B17 (o repro mínimo: cookie `municipios:votos` + load direto). +- **Causa-raiz:** o flight serializer emite um elemento passado como `children` de um Client Component como referência lazy (`$L`) quando a tarefa servidor cruza o orçamento `MAX_ROW_SIZE = 3200` de `serializedSize`. Com `votos` visível, a presença da coluna forçava a divisão do thead em tarefas por coluna (o span de `Dobradinha` serializava inline na tarefa própria); com `votos` oculta, o thead virou UMA tarefa de ~4,7 KB e o span de `Dobradinha` (cabeçalho `CampaignTableHead` com `description`) caiu exatamente na fronteira → deferido para chunk posterior → no SSR o `CampaignHoverTooltip` recebia o placeholder não-resolvido como `children` → `cloneElement(undefined)` → #130. O `className` de B172 foi o bytes-extra que moveu a fronteira para cima do span. +- **Correção (2 arquivos, sem mudança visual):** `CampaignTableHead` passa o rótulo do cabeçalho como prop de dados (`explanationLabel`) em vez de elemento `children` — string/ReactNode nunca é deferida pelo flight; o `CampaignHoverTooltip` reconstrói o trigger (``) a partir do label. Todos os outros call sites do tooltip (células, cards, sortable heads) seguem no caminho `children` original, inalterado. O e2e `campaignColumnPicker` passou a ser verde 3/3 e o `campaignMunicipalityResponsiveColumns` 4/4 em produção; a coluna `Dobradinha` continua exibindo o cabeçalho após a correção (verificado no DOM). diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index 236ab233..e016fdf0 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -169,11 +169,15 @@ type MunicipalityColumn = CampaignTableColumn & { const responsiveColumnClassName = { advisors: 'hidden @min-[54rem]/municipality-list:table-cell', + trend: 'hidden @min-[60rem]/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> +/** B172 — `actions` only exists below 60rem, while trend's own column is gone. */ +const actionsWidthGateClass = '@min-[60rem]/municipality-list:hidden' + const MunicipalityListSignalTrigger = ({ lastSignalAt, presentation, @@ -225,6 +229,10 @@ const municipalityListColumns = ({ const manuallyHidden = isStaffView ? new Set(columnVisibility.hiddenColumnIds) : new Set() const trendIsHidden = manuallyHidden.has('trend') const signalIsHidden = manuallyHidden.has('lastSignal') + // B172 — while the picker still shows trend, the `actions` column is gated + // closed by the same 60rem breakpoint that hides trend's own column (one + // visible editor per row per width); a manual hide pins the column open. + const actionsAlwaysVisible = trendIsHidden || signalIsHidden const renderTrendControl = ( municipality: MunicipalityListViewModel, @@ -499,8 +507,10 @@ const municipalityListColumns = ({ sortKey="trend" filterParam="trend" description={municipalityColumnDescriptions.trend} + className={responsiveColumnClassName.trend} /> ), + cellClassName: responsiveColumnClassName.trend, cell: (municipality) => renderTrendCell(municipality, 'adaptive'), }, { @@ -595,27 +605,42 @@ const municipalityListColumns = ({ ), cell: (municipality) => renderSignalCell(municipality, 'adaptive'), }, - ...(trendIsHidden || signalIsHidden - ? ([ - { - id: 'actions', - label: 'Ações', - mandatory: true, - head: ( - - Ações - - ), - cellClassName: 'text-right', - cell: (municipality) => ( -
- {trendIsHidden ? renderTrendCell(municipality, 'compact') : null} - {signalIsHidden ? renderSignalCell(municipality, 'compact') : null} -
- ), - }, - ] satisfies Array) - : []), + { + // B172 — below 60rem the trend column disappears + // (`responsiveColumnClassName.trend`) and the same editor is mounted + // here as a compact trigger. The column is always mounted so the + // width fallback has a home when the picker shows trend; it is gated + // closed above 60rem unless a manual hide pins it open. + id: 'actions', + label: 'Ações', + mandatory: true, + head: ( + + Ações + + ), + cellClassName: cn( + 'text-right', + actionsAlwaysVisible ? undefined : actionsWidthGateClass, + ), + cell: (municipality) => ( +
+ {/* The compact trend trigger replaces its own column on a manual + hide or only backs it up below 60rem — never both at once. */} + {trendIsHidden ? ( + renderTrendCell(municipality, 'compact') + ) : ( + + {renderTrendCell(municipality, 'compact')} + + )} + {signalIsHidden ? renderSignalCell(municipality, 'compact') : null} +
+ ), + }, ] satisfies Array) : ([ { diff --git a/src/components/campaign/shared/CampaignHoverTooltip.tsx b/src/components/campaign/shared/CampaignHoverTooltip.tsx index 3980776f..9bc5036e 100644 --- a/src/components/campaign/shared/CampaignHoverTooltip.tsx +++ b/src/components/campaign/shared/CampaignHoverTooltip.tsx @@ -4,6 +4,8 @@ import type { PointerEvent, ReactElement, ReactNode, Ref } from 'react' import { cloneElement, useEffect, useRef, useState } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { campaignHoverExplanationClassName } from '@/lib/campaignHoverTooltip' +import { cn } from '@/lib/utils' /** * Hover/focus explanation with no extra chrome — wraps an existing control, @@ -44,6 +46,8 @@ type TappableElement = ReactElement<{ export const CampaignHoverTooltip = ({ content, children, + explanationLabel, + filterAffordance = false, side = 'bottom', align = 'center', sideOffset = 4, @@ -51,7 +55,19 @@ export const CampaignHoverTooltip = ({ disabled = false, }: { content: ReactNode - children: TappableElement + children?: TappableElement + /** + * `CampaignTableHead`'s label, passed as DATA instead of as the `children` + * element. React 19.2.4's flight serializer defers an element passed as a + * client component's `children` when the enclosing server task crosses its + * row budget (MAX_ROW_SIZE) — during SSR the component then receives an + * unresolved lazy placeholder instead of the element, and cloning it + * produces "Element type is invalid: got undefined". A string/ReactNode + * prop is never deferred, so the trigger is rebuilt client-side from it. + */ + explanationLabel?: ReactNode + /** `CampaignTableHead`-only: keep the label's min-height when a filter sits beside it. */ + filterAffordance?: boolean side?: 'top' | 'right' | 'bottom' | 'left' align?: 'start' | 'center' | 'end' sideOffset?: number @@ -79,17 +95,54 @@ export const CampaignHoverTooltip = ({ return () => document.removeEventListener('pointerdown', closeIfOutside) }, [open]) - if (content === null || content === undefined || content === false) return children + if (content === null || content === undefined || content === false) { + return explanationLabel !== undefined ? ( + + {explanationLabel} + + ) : ( + children + ) + } - const trigger = cloneElement(children, { - ref: (node: HTMLElement | null) => { - triggerRef.current = node - }, - onPointerUp: (event: PointerEvent) => { - children.props.onPointerUp?.(event) - if (openOnTouch && event.pointerType === 'touch') setOpen((current) => !current) - }, - }) + // `explanationLabel` callers rebuild the trigger from the data prop; the + // shared path clones the caller's element. + const trigger = + explanationLabel !== undefined ? ( + { + triggerRef.current = node + }} + onPointerUp={(event) => { + if (openOnTouch && event.pointerType === 'touch') setOpen((current) => !current) + }} + > + {explanationLabel} + + ) : ( + cloneElement(children as TappableElement, { + ref: (node: HTMLElement | null) => { + triggerRef.current = node + }, + onPointerUp: (event: PointerEvent) => { + ;(children as TappableElement).props.onPointerUp?.(event) + if (openOnTouch && event.pointerType === 'touch') setOpen((current) => !current) + }, + }) + ) return ( setOpen(disabled ? false : next)}> diff --git a/src/components/campaign/shared/CampaignTable.tsx b/src/components/campaign/shared/CampaignTable.tsx index 560430de..514f6727 100644 --- a/src/components/campaign/shared/CampaignTable.tsx +++ b/src/components/campaign/shared/CampaignTable.tsx @@ -15,10 +15,7 @@ import { resolveVisibleColumns, type CampaignColumnVisibility, } from '@/lib/campaignColumnVisibility' -import { - campaignHoverExplanationClassName, - campaignHoverTooltipAlign, -} from '@/lib/campaignHoverTooltip' +import { campaignHoverTooltipAlign } from '@/lib/campaignHoverTooltip' import { cn } from '@/lib/utils' /** @@ -91,18 +88,12 @@ export const CampaignTableHead = ({ filter?: ReactNode }) => { const label = description ? ( - - - {children} - - + ) : filter ? ( {children} ) : ( diff --git a/tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts b/tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts index 0b19f6e5..4343fd6a 100644 --- a/tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts @@ -74,11 +74,15 @@ const expectedHeadersAt = (width: number) => { const headers = ['Município', '2022', '2026', 'Nível', 'Classe'] if (width >= 54 * REM_IN_PIXELS) headers.push('Assessores') - headers.push('Tendência') + // B172 — below 60rem the trend column disappears and its compact trigger + // moves into Ações; above it, the column shows the full badge and Ações has + // nothing to host (it is width-gated closed), so the two swap at 60rem. + if (width >= 60 * REM_IN_PIXELS) 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('Atualização') + if (width < 60 * REM_IN_PIXELS) headers.push('Ações') return headers } @@ -131,6 +135,15 @@ test.describe('B158 — colunas responsivas por largura do conteúdo', () => { expect(await visibleTriggerText(trendTrigger)).toBe( width >= 60 * REM_IN_PIXELS ? 'Não registrada' : '', ) + // B172 — below 60rem the column is gone and the compact trigger lives + // in Ações instead; `getByRole` skips display:none, so exactly one + // visible trend editor exists per row at every width. + await expect( + table + .locator('tbody tr') + .first() + .getByRole('button', { name: /^Editar tendência política/ }), + ).toHaveCount(1) expect(await visibleTriggerText(signalTrigger)).toBe( width >= 84 * REM_IN_PIXELS ? 'Sem sinal' : '', ) diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts index 7aa28429..60883fe6 100644 --- a/tests/unit/campaignComponents.unit.spec.ts +++ b/tests/unit/campaignComponents.unit.spec.ts @@ -768,6 +768,7 @@ describe('campaign visual foundation', () => { for (const className of [ 'hidden @min-[54rem]/municipality-list:table-cell', + 'hidden @min-[60rem]/municipality-list:table-cell', 'hidden @min-[66rem]/municipality-list:table-cell', 'hidden @min-[72rem]/municipality-list:table-cell', 'hidden @min-[78rem]/municipality-list:table-cell', @@ -785,19 +786,36 @@ describe('campaign visual foundation', () => { }) 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('')) + { hidden: [], trendEditors: 2, actionsGated: true }, + { hidden: ['trend'], trendEditors: 1, actionsGated: false }, + { hidden: ['lastSignal'], trendEditors: 2, actionsGated: false }, + { hidden: ['trend', 'lastSignal'], trendEditors: 1, actionsGated: false }, + ])( + // B172 — the trend editor is mounted once per placement (its own column + // above 60rem, the compact backup in Ações below). The backup only + // exists while the picker still shows the column, so the total is 2 for + // the visible case and 1 for the manual-hide case; the signal editor + // never gains a second placement (option A). `actions` is always mounted + // for staff and CSS-gated by container width when nothing is hidden. + 'keeps one signal editor and $trendEditors trend placements for $hidden', + ({ hidden, trendEditors, actionsGated }) => { + 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 atualização em/g)).toHaveLength(1) - expect(theadHtml.includes('Ações')).toBe(hasActions) - }) + expect(tableHtml.match(/aria-label="Editar tendência política/g)).toHaveLength(trendEditors) + expect(tableHtml.match(/aria-label="Registrar atualização em/g)).toHaveLength(1) + expect(theadHtml.includes('Ações')).toBe(true) + if (actionsGated) { + expect(theadHtml).toContain('@min-[60rem]/municipality-list:hidden') + } else { + expect(theadHtml).not.toContain('@min-[60rem]/municipality-list:hidden') + } + }, + ) it('keeps removed and internal columns out of the municipality picker', () => { const columns = municipalityListPickerColumns({