Merge branch 'cursor/46b02119'
This commit is contained in:
commit
4b60e9f1aa
9 changed files with 265 additions and 42 deletions
|
|
@ -7,6 +7,7 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **B23 entregue em código (2026-07-26):** tooltip de conteúdo nas células das listas — a mecânica genérica (`cellTooltip` em `CampaignTableColumn`, `CampaignCellTooltip`, `CampaignHoverTooltip` promovido para `shared/`) já tinha saído de graça no **E10 ✓**; este item fechou o que faltava: `openOnTouch`/`disabled` em `CampaignHoverTooltip` (default `true`/`false`, sem mudança nos 4 call sites pré-B23) para conviver com um Popover no mesmo gatilho sem roubar o tap nem sobreviver atrás dele — e, no `/simplify`, ganhou um terceiro comportamento: `content` falsy renderiza o `children` sem wrapper, então os dois consumidores abaixo não precisam mais de um ternário `content ? <CampaignHoverTooltip>… : children` cada; `formatAdvisorNamesTooltip` + `advisorEntriesFromIds` (dedupe do id→nome entre a lista e o controle) junto de `MunicipalityAdvisorAvatarStack.tsx`; e os dois consumidores de `/campanha/municipios` — célula "Assessores" (não-coordenador via `cellTooltip`; coordenador envolvendo o próprio `PopoverTrigger`, `openOnTouch={false}`/`disabled={open}`, nunca os dois ao mesmo tempo) e célula "Tendência" (tooltip só quando há `politicalTrendNote`; em toque a nota aparece como leitura dentro do próprio Popover, acima do select). Verificado manualmente em `/campanha/municipios` como coordenador e assessor: hover mostra nomes/justificativa, foco de teclado abre a mesma tooltip, clique/tap abre o Popover sem tooltip órfã atrás. Gate verde (tsc/lint/format/knip — P3 pré-existente/check:cycles/443 unit/387 int/build); Aikido 0 findings. Plano: [`tooltip-celulas-listas.md`](../../../docs/plans/tooltip-celulas-listas.md).
|
||||
- **B22 entregue em código (2026-07-26):** explicação por coluna no header de `/campanha/municipios` — `description?: ReactNode` em `CampaignTableHead` (`shared/CampaignTable.tsx`) e rename `tooltip`→`description` em `MunicipalitySortableHead`, ambos com o mesmo sublinhado pontilhado no label e o mesmo `CampaignHoverTooltip` compartilhado (já promovido para `shared/` pelo **E10 ✓**) — sem componente novo. Auditoria pré-implementação achou o escopo já reduzido: o primitivo de tooltip e o `cellTooltip` genérico já existiam (E10), então só faltava a prop em `CampaignTableHead` e o rename. Novo helper `campaignConceptOneLiner(id)` ao lado de `campaignConceptHref` em `campaignIntelligenceConcepts.ts` — fonte única para as colunas cobertas pelo **E18 ✓** (`expectedVotes` lê `oneLiner('meta')`, `goalCoverage` lê `oneLiner('cobertura-da-meta')` + a nota de cenário). Novo `MunicipalityListColumnId` + `municipalityColumnDescriptions` (`Record` de 11 entradas) em `municipalityLabels.ts` — a coluna "Classe" mantém o texto mais longo (`CLASS_COLUMN_DESCRIPTION`, movido de `MunicipalityList.tsx`) em vez do `oneLiner` terso de `classe-territorial`, decisão assumida porque uma classificação de 5 vias não cabe numa frase. `MunicipalityList.tsx` perdeu os três `*Hint` locais e a intersecção de tipo extra em `municipalityListColumns()`. Testes: pin do `campaignConceptOneLiner`, `describe('CampaignTableHead description')` (sem prop = render idêntico; com prop = 1 trigger, conteúdo fora do markup do servidor, mesmo contrato do `cellTooltip`), e contagem de `data-slot="tooltip-trigger"` no `<thead>` (10 staff / 5 leader) em `campaignComponents.unit.spec.ts`. Gate verde (tsc, lint, format, check:cycles, 442 unit / 387 int, build); `knip` segue com o erro pré-existente ao carregar `payload.config.ts` (ledgerado P3, confirmado idêntico fora desta mudança); Aikido sem achado. Fora do escopo (mantido do plano): copy nas outras 5 listas, tooltip nos cards mobile, variante Popover no touch. Plano: [`explicacao-colunas-header-listas.md`](../../../docs/plans/explicacao-colunas-header-listas.md).
|
||||
- **C13 entregue em código (2026-07-25):** "Plano de Ação" → **"Atividade"** em toda a vertical. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades", hooks internos renomeados), campo `campaignDemand.actionPlan` → `activity`, 7 utilities `actionPlan*` → `activity*`, `access/actionPlans.ts` → `access/activities.ts`, `lib/schemas/actionPlan.ts` → `activity.ts`, `actions/actionPlan.ts` → `activity.ts`, `components/campaign/actionPlan/` → `activity/` (13 componentes), rota `(app)/planos/` → `(app)/atividades/` (criação em `atividades/nova`, feminino como `demandas/nova`) e sidebar "Atividades". **Migration data-preserving escrita à mão** `20260725_213000_rename_action_plan_to_activity`: 4 tabelas, 3 enums, 2 colunas, 39 índices (4 pkeys inclusos), 14 FKs, 2 sequences — lista canônica tirada de `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` porque o snapshot do drizzle não conhece o índice parcial `action_plan_upcoming_start_at_idx` (escrito à mão no C8) e porque o gerador do Payload emitiria DROP+CREATE. O `.json` foi derivado do snapshot anterior por transformação textual (o `migrate:create` é interativo para renames) e validado pelo teste que importa: `pnpm migrate:create __probe` responde "No schema changes detected". **Bônus aprovado pelo usuário nesta sessão:** os **8 índices fósseis `%plaza%`** da remodelagem de 23/07 (que renomeou as colunas `plaza_id` mas não os índices, deixando banco e snapshot em desacordo) foram alinhados na mesma migration; `down()` os restaura, para não inventar um estado que nunca existiu. Decisões de vocabulário: sidebar **"Atividades"** (uma palavra só, a da entidade) e rótulos de campo alinhados — **"Tipo de atividade"**, **"Origem da atividade"**, **"Resultado da atividade"** — porque "ação" ao lado de "atividade" reintroduzia a tradução mental que o item existe para matar; valores de enum `kind`/`status`/`origin` **inalterados**. **Guard:** o describe "banned campaign terminology" de `codebaseConventions.unit.spec.ts` virou tabela de termos varrendo `src`+`tests`+`scripts` (migrations fora — história congelada), banindo os compostos `actionPlan`, a copy "plano de ação" e a rota velha, mas **não** `plan`/`plano` isolado (colidiria com os planos de assinatura da Fase 2 white-label); allowlist: o próprio spec, `scripts/generate-remodel-municipalities-migration.mjs` e o comentário histórico em `campaignMigrationReconciliation.int.spec.ts`. Estendê-lo a `tests/` derrubou 8 fósseis "Praça" em fixtures, limpos no mesmo PR. Gate verde (tsc, lint, format, knip, check:cycles, 46 unit / 48 int / 7 e2e, build); Aikido sem achado. **Deploy:** o `pnpm build` da Vercel aplica a migration enquanto o deployment antigo ainda serve — deployar fora do horário de mesa (mesmo perfil da remodelagem de 23/07). **Pós-`/simplify`:** a varredura mecânica comeu uma interpolação — `tests/unit/recentVisits.unit.spec.ts` ficou com `label: \`Município \`` para todas as entradas, e passava porque o label não era asserido (restaurado, com assertion nova para fechar a brecha, e os slugs `praca-` do fixture alinhados). O guard ganhou tolerância a espaço (`action[\s_-]?plan`), que era o buraco por onde `access/shared.ts` mantinha "action plan" em prosa; com isso a allowlist encolheu — o comentário de `campaignMigrationReconciliation.int.spec.ts` passou a citar `activity` e o arquivo saiu da exceção (uma spec que cresce não fica fora do guard por causa de um comentário). Deliberadamente **não** ampliei o padrão de "Praça" para a forma sem cedilha: cobraria três entradas novas de allowlist (nomes reais de organização na planilha, o teste de entradas legadas `kind: 'nucleus'`) por um fóssil já corrigido. Também: três resíduos do find-and-replace (banner `// Action activities`, `DOSSIER_*_PLAN_LIMIT`, "a activity" em dois comentários), concordância em pt-BR ("Criada por", "atividades apoiadas" minúsculo), o `??` impossível em `ActivityForm`, e as duas buscas de FK da migration agora usam `to_regclass` em vez de `::regclass` (que lança em vez de retornar NULL, contradizendo a invariante do próprio cabeçalho). A entrada morta `action_plan_municipality_idx` saiu da lista de índices da migration (nenhuma migration jamais criou esse nome — só os snapshots o afirmam, e com `push: false` nenhum banco pode carregá-lo), deixando `up()` e `down()` com os mesmos 48 pares; e o detalhe de organização parou de derivar o rótulo de status à mão e passou a reusar `ActivityStatusBadge`, com `OrganizationDetailViewModel.activities[].status` tipado como `ActivityStatus` em vez de `string` (a origem que forçava o re-narrowing no consumidor). Plano: [`renomear-plano-acao-para-atividade.md`](../../../docs/plans/renomear-plano-acao-para-atividade.md).
|
||||
- **B26 entregue em código (2026-07-25):** registrar sinal a partir da célula "Último sinal" de `/campanha/municipios` — Popover (desktop) / Drawer bottom sheet (mobile) cria `municipalityUpdate` `kind: 'sinal'` (C12) sem sair da fila; submit explícito "Registrar sinal" (exceção do princípio 4); `revalidateMunicipalityListPaths`; sem migration. Plano: [`registrar-sinal-lista-municipios.md`](../../../docs/plans/registrar-sinal-lista-municipios.md).
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
# Tooltip de conteúdo nas células das listas
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Status: entregue
|
||||
Atualizado em: 2026-07-26
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B23**)
|
||||
Impeccable: B — encaixe nas células "Assessores" e "Tendência" já existentes de `/campanha/municipios`, com a capacidade promovida para o sistema de listas compartilhado (`CampaignTable`)
|
||||
Appetite: ~0,75 dia eng; 1 prop em `CampaignTableColumn`, 1 wrapper na célula, 1 flag no primitivo de tooltip, 2 consumidores (Assessores nos dois papéis + justificativa da Tendência); sem migration
|
||||
Appetite: ~0,4 dia eng (revisado 2026-07-26 — a capacidade genérica de `cellTooltip`/`CampaignHoverTooltip` já tinha sido entregue de graça pelo **E10**, ver nota abaixo; o que restava era só os dois consumidores + `openOnTouch`/`disabled`); orçamento original ~0,75d
|
||||
Responsável: —
|
||||
|
||||
> **Nota de fechamento (2026-07-26):** quando este item foi implementado, `cellTooltip` em `CampaignTableColumn`, `CampaignCellTooltip` e a promoção de `MunicipalityHoverTooltip` → `shared/CampaignHoverTooltip` **já existiam no código**, entregues como efeito colateral do **E10** (2026-07-25, coluna "Classe"). O texto abaixo ("Contexto", parte de "Decisões travadas") foi escrito antes disso e ainda descreve essa promoção como trabalho deste item — histórico, não corrigido retroativamente. O que este item de fato implementou: `openOnTouch`/`disabled` em `CampaignHoverTooltip`, `formatAdvisorNamesTooltip`, e os dois consumidores (célula "Assessores" nos dois papéis + célula "Tendência" com a nota lida no Popover).
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípio 2 — clareza sob pressão; princípio 3 — edit where you see; anti-goal spreadsheet/data-grid) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · shells existentes: `CampaignTable` (`src/components/campaign/shared/CampaignTable.tsx`), `MunicipalityHoverTooltip` → `CampaignHoverTooltip` (**B22**), `MunicipalityAdvisorAvatarStack`, `MunicipalityListAdvisorsControl`.
|
||||
|
|
@ -124,6 +126,8 @@ Componentes:
|
|||
- **Adotar `cellTooltip` em outras colunas/listas** (Território truncado, dobradinhas por município). Revisitar quando: alguém do time relatar em sessão/R6 não conseguir ler uma célula, ou quando uma coluna nova nascer com conteúdo abreviado.
|
||||
- **Justificativa em tooltip nas outras superfícies de tendência** (`MunicipalityStrategyCard`, dossiê). Revisitar quando: alguma delas passar a esconder a nota por densidade — hoje ambas a exibem por extenso.
|
||||
- **Affordance visual (sublinhado pontilhado) na célula.** Revisitar quando: a primeira coluna de **texto** adotar a capacidade, ou o critique do R6 registrar falha de descoberta.
|
||||
- **Paridade de leitor de tela para a nota de tendência.** A célula de assessores expõe todos os nomes em `sr-only` independente de hover (`MunicipalityAdvisorAvatarStack`); a de tendência não tem equivalente — `trendNote` só existe dentro do `CampaignHoverTooltip`/`PopoverContent`, ambos lazy-mount (contrato pinado em `campaignComponents.unit.spec.ts`: `not.toContain(trendNote)` na renderização estática). Achado do `/simplify` pós-rebase (2026-07-26), não corrigido nesta entrega porque adicionar um `sr-only` ali quebraria esse contrato de propósito e pede decisão explícita se a paridade vale o "duplo anúncio" para leitor de tela. Revisitar quando: um relato de acessibilidade real aparecer, ou o critique do R6 flagar hover-only content de novo.
|
||||
- **Wrapper compartilhado `Popover` + `CampaignHoverTooltip`.** `MunicipalityListAdvisorsControl` e `MunicipalityListTrendControl` repetem a mesma composição (`openOnTouch={false}`, `disabled={open}`, `align="start"`) — 2º call site, abaixo do piso de 3 para extrair (achado do `/simplify` pós-rebase, 2026-07-26). Revisitar quando: uma 3ª célula precisar da mesma combinação Popover+tooltip (checado nesta sessão: `MunicipalityListSignalControl`/B26 não precisa — o `aria-label` do gatilho já carrega o texto por extenso).
|
||||
|
||||
## Referências
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,4 +1,5 @@
|
|||
import { CircleAlertIcon } from 'lucide-react'
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
|
|
@ -10,6 +11,16 @@ export type MunicipalityAdvisorAvatarEntry = {
|
|||
name: string
|
||||
}
|
||||
|
||||
/** Resolves advisor ids to `{id, name}` entries, dropping any without a name (deleted/unknown user). */
|
||||
export const advisorEntriesFromIds = (
|
||||
advisorIDs: number[],
|
||||
advisorNamesById: ReadonlyMap<number, { id: number; name: string }>,
|
||||
): MunicipalityAdvisorAvatarEntry[] =>
|
||||
advisorIDs.flatMap((id) => {
|
||||
const advisor = advisorNamesById.get(id)
|
||||
return advisor ? [{ id: advisor.id, name: advisor.name }] : []
|
||||
})
|
||||
|
||||
/**
|
||||
* E9: a priority município with nobody answering for it is the queue's loudest
|
||||
* row, so it reads "Sem responsável" in destructive — the same words the
|
||||
|
|
@ -24,6 +35,18 @@ export const MissingAdvisorBadge = ({ isPriority }: { isPriority: boolean }) =>
|
|||
</Badge>
|
||||
)
|
||||
|
||||
/** One advisor name per line, for the tooltip — `null` when the list is empty (that's `MissingAdvisorBadge`'s job). */
|
||||
export const formatAdvisorNamesTooltip = (
|
||||
advisors: MunicipalityAdvisorAvatarEntry[],
|
||||
): ReactElement | null =>
|
||||
advisors.length === 0 ? null : (
|
||||
<div className="flex flex-col">
|
||||
{advisors.map((advisor) => (
|
||||
<span key={advisor.id}>{advisor.name}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const MunicipalityAdvisorAvatarStack = ({
|
||||
advisors,
|
||||
isPriority,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { CircleAlertIcon, SearchXIcon } from 'lucide-react'
|
|||
import Link from 'next/link'
|
||||
|
||||
import {
|
||||
advisorEntriesFromIds,
|
||||
formatAdvisorNamesTooltip,
|
||||
MissingAdvisorBadge,
|
||||
MunicipalityAdvisorAvatarStack,
|
||||
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
|
||||
|
|
@ -230,11 +232,7 @@ const MunicipalityListEmptyState = ({ state }: { state: MunicipalityListState })
|
|||
const advisorEntries = (
|
||||
municipality: MunicipalityListViewModel,
|
||||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
|
||||
) =>
|
||||
municipality.advisorIDs.flatMap((id) => {
|
||||
const advisor = advisorNamesById.get(id)
|
||||
return advisor ? [{ id: advisor.id, name: advisor.name }] : []
|
||||
})
|
||||
) => advisorEntriesFromIds(municipality.advisorIDs, advisorNamesById)
|
||||
|
||||
const advisorNames = (
|
||||
municipality: MunicipalityListViewModel,
|
||||
|
|
@ -387,6 +385,13 @@ const municipalityListColumns = ({
|
|||
isPriority={municipality.priority === 'alta'}
|
||||
/>
|
||||
),
|
||||
// The coordinator's cell already wraps its own Popover trigger in a
|
||||
// tooltip (see `MunicipalityListAdvisorsControl`) — declaring it
|
||||
// here too would double it up on the same gesture.
|
||||
cellTooltip: (municipality) =>
|
||||
isCoordinator
|
||||
? null
|
||||
: formatAdvisorNamesTooltip(advisorEntries(municipality, advisorNamesById)),
|
||||
},
|
||||
{
|
||||
id: 'trend',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@
|
|||
import { useActionState, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { MunicipalityAdvisorAvatarStack } from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
|
||||
import {
|
||||
advisorEntriesFromIds,
|
||||
formatAdvisorNamesTooltip,
|
||||
MunicipalityAdvisorAvatarStack,
|
||||
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
|
||||
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
|
|
@ -43,10 +48,8 @@ export const MunicipalityListAdvisorsControl = ({
|
|||
const [state, submitAction, isPending] = useActionState(formAction, {})
|
||||
const currentSet = new Set(currentAdvisorIDs)
|
||||
|
||||
const names = currentAdvisorIDs.flatMap((id) => {
|
||||
const advisor = advisorNamesById.get(id)
|
||||
return advisor ? [{ id: advisor.id, name: advisor.name }] : []
|
||||
})
|
||||
const names = advisorEntriesFromIds(currentAdvisorIDs, advisorNamesById)
|
||||
const tooltipContent = formatAdvisorNamesTooltip(names)
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== 'success') return
|
||||
|
|
@ -56,15 +59,25 @@ export const MunicipalityListAdvisorsControl = ({
|
|||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Editar assessores"
|
||||
>
|
||||
<MunicipalityAdvisorAvatarStack advisors={names} isPriority={isPriority} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
{/* No advisors: the button already reads "Sem responsável"/"Sem assessor"
|
||||
by extenso, so a tooltip would only echo it (`tooltipContent` is
|
||||
null — see `CampaignHoverTooltip` for the no-op contract). */}
|
||||
<CampaignHoverTooltip
|
||||
content={tooltipContent}
|
||||
align="start"
|
||||
openOnTouch={false}
|
||||
disabled={open}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Editar assessores"
|
||||
>
|
||||
<MunicipalityAdvisorAvatarStack advisors={names} isPriority={isPriority} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</CampaignHoverTooltip>
|
||||
<PopoverContent align="start" className="w-80">
|
||||
<form action={submitAction} className="flex flex-col gap-3">
|
||||
<input type="hidden" name="municipalityId" value={municipalityID} />
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useActionState, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -41,28 +42,40 @@ export const MunicipalityListTrendControl = ({
|
|||
setOpen(false)
|
||||
}, [state.message, state.status])
|
||||
|
||||
const hasNote = Boolean(trendNote?.trim())
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Editar tendência política"
|
||||
>
|
||||
{status ? (
|
||||
<Badge variant={politicalTrendBadgeVariant[status]}>
|
||||
{politicalTrendLabels[status]}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Não registrada</Badge>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<CampaignHoverTooltip
|
||||
content={hasNote ? <p className="whitespace-pre-wrap">{trendNote}</p> : null}
|
||||
align="start"
|
||||
openOnTouch={false}
|
||||
disabled={open}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Editar tendência política"
|
||||
>
|
||||
{status ? (
|
||||
<Badge variant={politicalTrendBadgeVariant[status]}>
|
||||
{politicalTrendLabels[status]}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Não registrada</Badge>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</CampaignHoverTooltip>
|
||||
<PopoverContent align="start" className="w-72">
|
||||
<form action={submitAction} className="flex flex-col gap-3">
|
||||
<input type="hidden" name="municipalityId" value={municipalityID} />
|
||||
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
|
||||
<input type="hidden" name="trendNote" value={trendNote ?? ''} />
|
||||
{hasNote ? (
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{trendNote}</p>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`municipality-list-trend-${municipalityID}`}>Tendência</FieldLabel>
|
||||
<NativeSelect
|
||||
|
|
|
|||
|
|
@ -25,6 +25,16 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
|||
* what keeps at most one of these tooltips open at a time. The content itself
|
||||
* counts as "inside": it may hold a link (E18's "Saiba mais"), and closing on
|
||||
* `pointerdown` would unmount that link before its click ever landed.
|
||||
*
|
||||
* `openOnTouch` and `disabled` (B23) exist for triggers that also open a
|
||||
* Popover — the coordinator's advisor/trend quick-edit cells (B9/B19). A tap
|
||||
* there must open the edit surface, not this tooltip (`openOnTouch={false}`),
|
||||
* and once that Popover is open the tooltip must not linger behind it
|
||||
* (`disabled={open}` from the caller). Neither prop changes behavior for the
|
||||
* four pre-B23 call sites, which don't pass them.
|
||||
*
|
||||
* Falsy `content` renders `children` unwrapped (no tooltip, no `TooltipProvider`)
|
||||
* so callers can wrap a trigger unconditionally instead of a `content ? <CampaignHoverTooltip>… : children` ternary.
|
||||
*/
|
||||
type TappableElement = ReactElement<{
|
||||
onPointerUp?: (event: PointerEvent) => void
|
||||
|
|
@ -51,17 +61,25 @@ export const CampaignHoverTooltip = ({
|
|||
side = 'bottom',
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
openOnTouch = true,
|
||||
disabled = false,
|
||||
}: {
|
||||
content: ReactNode
|
||||
children: TappableElement
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
align?: 'start' | 'center' | 'end'
|
||||
sideOffset?: number
|
||||
openOnTouch?: boolean
|
||||
disabled?: boolean
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef<HTMLElement | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) setOpen(false)
|
||||
}, [disabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const closeIfOutside = (event: globalThis.PointerEvent) => {
|
||||
|
|
@ -75,19 +93,21 @@ export const CampaignHoverTooltip = ({
|
|||
return () => document.removeEventListener('pointerdown', closeIfOutside)
|
||||
}, [open])
|
||||
|
||||
if (content === null || content === undefined || content === false) return children
|
||||
|
||||
const trigger = cloneElement(children, {
|
||||
ref: (node: HTMLElement | null) => {
|
||||
triggerRef.current = node
|
||||
},
|
||||
onPointerUp: (event: PointerEvent) => {
|
||||
children.props.onPointerUp?.(event)
|
||||
if (event.pointerType === 'touch') setOpen((current) => !current)
|
||||
if (openOnTouch && event.pointerType === 'touch') setOpen((current) => !current)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip open={open} onOpenChange={setOpen}>
|
||||
<Tooltip open={!disabled && open} onOpenChange={(next) => setOpen(disabled ? false : next)}>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
ref={contentRef}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ import { describe, expect, it, vi } from 'vitest'
|
|||
import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'
|
||||
|
||||
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
|
||||
import { formatAdvisorNamesTooltip } from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
|
||||
import { MunicipalityList } 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'
|
||||
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
|
||||
import { Progress } from '@/components/ui/Progress'
|
||||
|
|
@ -481,4 +484,145 @@ describe('campaign visual foundation', () => {
|
|||
const [theadHtml] = html.split('<tbody')
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(5)
|
||||
})
|
||||
|
||||
/**
|
||||
* B23: the advisor stack only shows up to 3 initials (and initials
|
||||
* collide), so the full names ride along as redundant text. Radix mounts
|
||||
* tooltip content only once opened, so these pin the trigger half of the
|
||||
* contract — the same shape `campaignTable.unit.spec.ts` pins generically.
|
||||
*/
|
||||
describe('B23 cell tooltips', () => {
|
||||
it('formats one advisor name per line and returns null for an empty list', () => {
|
||||
expect(formatAdvisorNamesTooltip([])).toBeNull()
|
||||
|
||||
const one = renderToStaticMarkup(formatAdvisorNamesTooltip([{ id: 1, name: 'Ana Bastos' }])!)
|
||||
expect(one).toContain('Ana Bastos')
|
||||
|
||||
const three = renderToStaticMarkup(
|
||||
formatAdvisorNamesTooltip([
|
||||
{ id: 1, name: 'Ana Bastos' },
|
||||
{ id: 2, name: 'Beto Lima' },
|
||||
{ id: 3, name: 'Carla Dias' },
|
||||
])!,
|
||||
)
|
||||
expect(three).toContain('Ana Bastos')
|
||||
expect(three).toContain('Beto Lima')
|
||||
expect(three).toContain('Carla Dias')
|
||||
})
|
||||
|
||||
it('wraps the advisor cell in a tooltip only when there is a name to show', () => {
|
||||
// The staff `<thead>` 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 = 10
|
||||
const baseMunicipality = {
|
||||
id: 1,
|
||||
name: 'Seabra',
|
||||
slug: 'seabra',
|
||||
kind: 'municipio' as const,
|
||||
city: 'Seabra',
|
||||
region: 'Chapada Diamantina',
|
||||
ibgeCode: '2929800',
|
||||
zoneNumber: null,
|
||||
priority: 'normal' as const,
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
politicalTrendStatus: null,
|
||||
politicalTrendNote: null,
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
votePosition2022: null,
|
||||
territorialClass: 'sem_base' as const,
|
||||
territorialClassFactors: [],
|
||||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||||
}
|
||||
|
||||
const withAdvisors = renderToStaticMarkup(
|
||||
createElement(MunicipalityList, {
|
||||
municipalities: [{ ...baseMunicipality, advisorIDs: [7] }],
|
||||
advisorNamesById: new Map([[7, { id: 7, name: 'Ana Bastos', phone: null }]]),
|
||||
isStaffView: true,
|
||||
...municipalityListDefaultProps,
|
||||
}),
|
||||
)
|
||||
expect(withAdvisors.match(/data-slot="tooltip-trigger"/g)).toHaveLength(
|
||||
HEADER_TOOLTIP_COUNT + 1,
|
||||
)
|
||||
|
||||
const withoutAdvisors = renderToStaticMarkup(
|
||||
createElement(MunicipalityList, {
|
||||
municipalities: [{ ...baseMunicipality, advisorIDs: [] }],
|
||||
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
|
||||
isStaffView: true,
|
||||
...municipalityListDefaultProps,
|
||||
}),
|
||||
)
|
||||
expect(withoutAdvisors.match(/data-slot="tooltip-trigger"/g)).toHaveLength(
|
||||
HEADER_TOOLTIP_COUNT,
|
||||
)
|
||||
expect(withoutAdvisors).toContain('Sem assessor')
|
||||
})
|
||||
|
||||
it("wraps the coordinator's advisor Popover trigger in a tooltip, never doubling the affordance", () => {
|
||||
const withAdvisors = renderToStaticMarkup(
|
||||
createElement(MunicipalityListAdvisorsControl, {
|
||||
municipalityID: 1,
|
||||
municipalitySlug: 'seabra',
|
||||
currentAdvisorIDs: [7],
|
||||
isPriority: false,
|
||||
advisorNamesById: new Map([[7, { id: 7, name: 'Ana Bastos', phone: null }]]),
|
||||
options: [],
|
||||
formAction: noopListFormAction,
|
||||
}),
|
||||
)
|
||||
expect(withAdvisors.match(/data-slot="tooltip-trigger"/g)).toHaveLength(1)
|
||||
// The tooltip wraps the SAME trigger — nesting `asChild` composition
|
||||
// means only the outer wrapper's `data-slot` survives on the button,
|
||||
// but Popover's own trigger wiring (still the innermost primitive)
|
||||
// stays intact and functional.
|
||||
expect(withAdvisors).toContain('aria-haspopup="dialog"')
|
||||
|
||||
const withoutAdvisors = renderToStaticMarkup(
|
||||
createElement(MunicipalityListAdvisorsControl, {
|
||||
municipalityID: 1,
|
||||
municipalitySlug: 'seabra',
|
||||
currentAdvisorIDs: [],
|
||||
isPriority: false,
|
||||
advisorNamesById: new Map(),
|
||||
options: [],
|
||||
formAction: noopListFormAction,
|
||||
}),
|
||||
)
|
||||
expect(withoutAdvisors).not.toContain('data-slot="tooltip-trigger"')
|
||||
expect(withoutAdvisors).toContain('data-slot="popover-trigger"')
|
||||
expect(withoutAdvisors).toContain('aria-haspopup="dialog"')
|
||||
})
|
||||
|
||||
it('shows the trend justification on hover only when one was recorded', () => {
|
||||
const withNote = renderToStaticMarkup(
|
||||
createElement(MunicipalityListTrendControl, {
|
||||
municipalityID: 1,
|
||||
municipalitySlug: 'seabra',
|
||||
status: 'favoravel',
|
||||
trendNote: 'Vereador migrou para a base',
|
||||
formAction: noopListFormAction,
|
||||
}),
|
||||
)
|
||||
expect(withNote.match(/data-slot="tooltip-trigger"/g)).toHaveLength(1)
|
||||
// Radix mounts tooltip content lazily — it never reaches the server
|
||||
// markup, same contract as `campaignTable.unit.spec.ts`.
|
||||
expect(withNote).not.toContain('Vereador migrou para a base')
|
||||
|
||||
const withoutNote = renderToStaticMarkup(
|
||||
createElement(MunicipalityListTrendControl, {
|
||||
municipalityID: 1,
|
||||
municipalitySlug: 'seabra',
|
||||
status: 'favoravel',
|
||||
trendNote: null,
|
||||
formAction: noopListFormAction,
|
||||
}),
|
||||
)
|
||||
expect(withoutNote).not.toContain('data-slot="tooltip-trigger"')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue