mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 17:43:04 +00:00
feat: make municipality columns follow content width
This commit is contained in:
parent
423c61d40b
commit
150bea2621
15 changed files with 946 additions and 421 deletions
4
.github/workflows/ci-pr.yml
vendored
4
.github/workflows/ci-pr.yml
vendored
|
|
@ -311,8 +311,8 @@ jobs:
|
|||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Install Playwright chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Install Playwright Chromium and focused WebKit
|
||||
run: pnpm exec playwright install --with-deps chromium webkit
|
||||
- run: pnpm migrate
|
||||
- run: pnpm db:seed:minimal
|
||||
- name: Build for e2e
|
||||
|
|
|
|||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -133,8 +133,8 @@ jobs:
|
|||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Install Playwright chromium
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Install Playwright Chromium and focused WebKit
|
||||
run: pnpm exec playwright install --with-deps chromium webkit
|
||||
- run: pnpm migrate
|
||||
- run: pnpm db:seed:minimal
|
||||
- name: Build for e2e
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -75,6 +75,12 @@ export default defineConfig({
|
|||
dependencies: ['setup'],
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
|
||||
},
|
||||
{
|
||||
name: 'municipality-responsive-webkit',
|
||||
testMatch: /campaignMunicipalityResponsiveColumns\.e2e\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: { ...devices['iPad Pro 11'] },
|
||||
},
|
||||
{
|
||||
name: 'frontend',
|
||||
testMatch: /frontend\.e2e\.spec\.ts/,
|
||||
|
|
|
|||
|
|
@ -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<MunicipalityListViewModel> & {
|
||||
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<Record<MunicipalityTableColumnId, string>>
|
||||
|
||||
const MunicipalityListSignalTrigger = ({
|
||||
lastSignalAt,
|
||||
presentation,
|
||||
}: {
|
||||
lastSignalAt: string | null
|
||||
presentation: 'compact' | 'adaptive'
|
||||
}) => {
|
||||
const isCold = isMunicipalitySignalCold(municipalitySignalAgeInDays(lastSignalAt))
|
||||
const Icon = isCold ? CircleAlertIcon : ActivityIcon
|
||||
const compact = (
|
||||
<Icon
|
||||
className={cn(
|
||||
'size-4',
|
||||
isCold ? 'text-estimate-pending-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
|
||||
if (presentation === 'compact') return compact
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="@min-[84rem]/municipality-list:hidden">{compact}</span>
|
||||
<span className="hidden @min-[84rem]/municipality-list:block">
|
||||
<SignalAgeReadout lastSignalAt={lastSignalAt} layout="table" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const municipalityListColumns = ({
|
||||
|
|
@ -174,326 +218,373 @@ const municipalityListColumns = ({
|
|||
stateDeputyCommitAction,
|
||||
stateDeputyCreateAction,
|
||||
signalFormAction,
|
||||
}: MunicipalityListProps): Array<MunicipalityColumn> => [
|
||||
{
|
||||
id: 'name',
|
||||
label: municipalityColumnLabels.name,
|
||||
mandatory: true,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="name"
|
||||
filterParam="name"
|
||||
filterOptions={columnFilterOptions.name}
|
||||
showPriorityFilter={isStaffView}
|
||||
description={municipalityColumnDescriptions.name}
|
||||
className="sticky left-0 z-20 min-w-56 bg-background"
|
||||
columnVisibility,
|
||||
}: MunicipalityListProps): Array<MunicipalityColumn> => {
|
||||
const manuallyHidden = new Set(columnVisibility.hiddenColumnIds)
|
||||
const trendIsHidden = manuallyHidden.has('trend')
|
||||
const signalIsHidden = manuallyHidden.has('lastSignal')
|
||||
|
||||
const renderTrendControl = (
|
||||
municipality: MunicipalityListViewModel,
|
||||
triggerPresentation: 'compact' | 'adaptive',
|
||||
) => (
|
||||
<MunicipalityListTrendControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
status={municipality.politicalTrendStatus}
|
||||
trendNote={municipality.politicalTrendNote}
|
||||
variant="popover"
|
||||
triggerPresentation={triggerPresentation}
|
||||
/>
|
||||
)
|
||||
|
||||
const renderSignalControl = (
|
||||
municipality: MunicipalityListViewModel,
|
||||
triggerPresentation: 'compact' | 'adaptive',
|
||||
) => (
|
||||
<MunicipalityListSignalControl
|
||||
municipalityID={municipality.id}
|
||||
municipalitySlug={municipality.slug}
|
||||
municipalityName={municipality.name}
|
||||
lastSignalAt={municipality.lastSignalAt}
|
||||
variant="popover"
|
||||
formAction={signalFormAction}
|
||||
>
|
||||
<MunicipalityListSignalTrigger
|
||||
lastSignalAt={municipality.lastSignalAt}
|
||||
presentation={triggerPresentation}
|
||||
/>
|
||||
),
|
||||
cellClassName: 'sticky left-0 z-[5] min-w-56 whitespace-normal bg-background',
|
||||
cell: (municipality) => (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/campanha/municipios/${municipality.slug}`}
|
||||
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{municipality.name}
|
||||
</Link>
|
||||
{municipality.priority === 'alta' && isStaffView ? <MunicipalityPriorityIndicator /> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'region',
|
||||
label: municipalityColumnLabels.region,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="region"
|
||||
filterParam="region"
|
||||
filterOptions={columnFilterOptions.region}
|
||||
description={municipalityColumnDescriptions.region}
|
||||
/>
|
||||
),
|
||||
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
|
||||
cell: (municipality) => <TerritoryLink region={municipality.region} />,
|
||||
},
|
||||
{
|
||||
id: 'votos',
|
||||
label: municipalityColumnLabels.votos,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="votos"
|
||||
align="right"
|
||||
description={municipalityColumnDescriptions.votos}
|
||||
/>
|
||||
),
|
||||
cellClassName: 'text-right',
|
||||
cell: (municipality) =>
|
||||
municipality.votePosition2022 ? (
|
||||
<MunicipalityVotePositionReadout position={municipality.votePosition2022} layout="table" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
</MunicipalityListSignalControl>
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'name',
|
||||
label: municipalityColumnLabels.name,
|
||||
mandatory: true,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="name"
|
||||
filterParam="name"
|
||||
filterOptions={columnFilterOptions.name}
|
||||
showPriorityFilter={isStaffView}
|
||||
description={municipalityColumnDescriptions.name}
|
||||
className="sticky left-0 z-20 w-52 max-w-52 whitespace-normal bg-background"
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(isStaffView
|
||||
? ([
|
||||
{
|
||||
// Sits right after "2022": both read the same TSE artifact, and the
|
||||
// class is the one-word summary of the votes beside it.
|
||||
id: 'classe',
|
||||
label: municipalityColumnLabels.classe,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="classe"
|
||||
filterParam="class"
|
||||
description={municipalityColumnDescriptions.classe}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) => <TerritorialClassReadout municipality={municipality} />,
|
||||
// The factors behind the label — the cell keeps them as `sr-only`
|
||||
// text, so this stays a redundant affordance (see `cellTooltip`).
|
||||
cellTooltip: (municipality) =>
|
||||
municipality.territorialClass === 'sem_base'
|
||||
? null
|
||||
: formatTerritorialClassWhy(municipality.territorialClassFactors),
|
||||
},
|
||||
{
|
||||
// Right after "Classe": the pair reads diagnosis then decision.
|
||||
id: 'level',
|
||||
label: municipalityColumnLabels.level,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="nivel"
|
||||
filterParam="level"
|
||||
description={municipalityColumnDescriptions.level}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) =>
|
||||
canMoveEngagementLevel ? (
|
||||
<MunicipalityListLevelControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
level={municipality.engagementLevel}
|
||||
levelNote={municipality.levelNote}
|
||||
levelChangedAt={municipality.levelChangedAt}
|
||||
variant="popover"
|
||||
/>
|
||||
) : (
|
||||
<MunicipalityLevelBadge
|
||||
level={municipality.engagementLevel}
|
||||
note={municipality.levelNote}
|
||||
layout="table"
|
||||
cellClassName: 'sticky left-0 z-[5] w-52 max-w-52 whitespace-normal bg-background',
|
||||
cell: (municipality) => (
|
||||
<div className="flex min-w-0 max-w-52 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-start gap-1.5">
|
||||
<Link
|
||||
href={`/campanha/municipios/${municipality.slug}`}
|
||||
className="line-clamp-2 min-h-11 min-w-0 flex-1 py-2 font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{municipality.name}
|
||||
</Link>
|
||||
{municipality.priority === 'alta' && isStaffView ? (
|
||||
<span className="mt-2 shrink-0">
|
||||
<MunicipalityPriorityIndicator />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
<TerritoryLink region={municipality.region} />
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'votos',
|
||||
label: municipalityColumnLabels.votos,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="votos"
|
||||
align="right"
|
||||
description={municipalityColumnDescriptions.votos}
|
||||
/>
|
||||
),
|
||||
cellClassName: 'text-right',
|
||||
cell: (municipality) =>
|
||||
municipality.votePosition2022 ? (
|
||||
<MunicipalityVotePositionReadout
|
||||
position={municipality.votePosition2022}
|
||||
layout="table"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
...(isStaffView
|
||||
? ([
|
||||
{
|
||||
id: 'expectedVotes',
|
||||
label: municipalityColumnLabels.expectedVotes,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="expectedVotes"
|
||||
align="center"
|
||||
description={municipalityColumnDescriptions.expectedVotes}
|
||||
>
|
||||
2026
|
||||
</MunicipalitySortableHead>
|
||||
),
|
||||
cellClassName: 'relative overflow-visible align-middle text-center tabular-nums',
|
||||
cell: (municipality) => (
|
||||
<div className="flex min-h-11 items-center justify-center">
|
||||
<MunicipalityListExpectedVotesControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
expectedVotes={municipality.expectedVotes}
|
||||
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
|
||||
variant="popover"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
// The declared investment follows the 2026 estimate: diagnosis and
|
||||
// decision stay together before the derived territorial class.
|
||||
id: 'level',
|
||||
label: municipalityColumnLabels.level,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="nivel"
|
||||
filterParam="level"
|
||||
description={municipalityColumnDescriptions.level}
|
||||
/>
|
||||
),
|
||||
// The editable cell wraps its own trigger in a tooltip; declaring it
|
||||
// here too would double it up on the same gesture (as in `advisors`).
|
||||
cellTooltip: (municipality) =>
|
||||
canMoveEngagementLevel || !municipality.engagementLevel
|
||||
? null
|
||||
: [formatEngagementLevelLabel(municipality.engagementLevel), municipality.levelNote]
|
||||
.filter(Boolean)
|
||||
.join(' — '),
|
||||
},
|
||||
{
|
||||
id: 'advisors',
|
||||
label: municipalityColumnLabels.advisors,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="coverage"
|
||||
filterParam="advisor"
|
||||
filterOptions={columnFilterOptions.advisor}
|
||||
description={municipalityColumnDescriptions.advisors}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) =>
|
||||
isCoordinator ? (
|
||||
<MunicipalityListAdvisorsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
currentAdvisorIDs={municipality.advisorIDs}
|
||||
isPriority={municipality.priority === 'alta'}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
variant="popover"
|
||||
/>
|
||||
) : (
|
||||
<MunicipalityAdvisorAvatarStack
|
||||
advisors={advisorEntries(municipality, advisorNamesById)}
|
||||
isPriority={municipality.priority === 'alta'}
|
||||
cell: (municipality) =>
|
||||
canMoveEngagementLevel ? (
|
||||
<MunicipalityListLevelControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
level={municipality.engagementLevel}
|
||||
levelNote={municipality.levelNote}
|
||||
levelChangedAt={municipality.levelChangedAt}
|
||||
variant="popover"
|
||||
/>
|
||||
) : municipality.engagementLevel ? (
|
||||
<MunicipalityLevelBadge
|
||||
level={municipality.engagementLevel}
|
||||
note={municipality.levelNote}
|
||||
layout="table"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
// The editable cell wraps its own trigger in a tooltip; declaring it
|
||||
// here too would double it up on the same gesture (as in `advisors`).
|
||||
cellTooltip: (municipality) =>
|
||||
canMoveEngagementLevel || !municipality.engagementLevel
|
||||
? null
|
||||
: [formatEngagementLevelLabel(municipality.engagementLevel), municipality.levelNote]
|
||||
.filter(Boolean)
|
||||
.join(' — '),
|
||||
},
|
||||
{
|
||||
id: 'classe',
|
||||
label: municipalityColumnLabels.classe,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="classe"
|
||||
filterParam="class"
|
||||
description={municipalityColumnDescriptions.classe}
|
||||
/>
|
||||
),
|
||||
// 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)),
|
||||
},
|
||||
...(isCampaignUnrestricted
|
||||
? ([
|
||||
{
|
||||
// B157 — "who doubles with us here", next to "who takes care of
|
||||
// it": the advisors-style avatar stack + hover detail, with an
|
||||
// editor (Popover on desktop / Drawer on mobile) that also
|
||||
// creates dobradinhas inline. Coordinator + candidate only.
|
||||
id: 'stateDeputies',
|
||||
label: municipalityColumnLabels.stateDeputies,
|
||||
head: (
|
||||
<CampaignTableHead description={municipalityColumnDescriptions.stateDeputies}>
|
||||
{municipalityColumnLabels.stateDeputies}
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
cell: (municipality) => (
|
||||
<MunicipalityStateDeputyRelationCell
|
||||
municipalityId={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
stateDeputyIDs={municipality.stateDeputyIDs}
|
||||
options={stateDeputyOptions}
|
||||
commitAction={stateDeputyCommitAction}
|
||||
createAction={stateDeputyCreateAction}
|
||||
editorVariant="popover"
|
||||
/>
|
||||
),
|
||||
},
|
||||
] satisfies Array<MunicipalityColumn>)
|
||||
: []),
|
||||
{
|
||||
// Right after "Assessores": the two relations of "who" in the
|
||||
// município sit together, and both are editable by all staff (the
|
||||
// advisor only within their portfolio, enforced server-side).
|
||||
id: 'leaderships',
|
||||
label: municipalityColumnLabels.leaderships,
|
||||
// No sort, no filter — the B22 explanation rides on the plain head.
|
||||
head: (
|
||||
<CampaignTableHead description={municipalityColumnDescriptions.leaderships}>
|
||||
{municipalityColumnLabels.leaderships}
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<MunicipalityListLeadershipsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
currentLeadershipIDs={municipality.leadershipIDs}
|
||||
leadershipNamesById={leadershipNamesById}
|
||||
options={leadershipOptions}
|
||||
variant="popover"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'trend',
|
||||
label: municipalityColumnLabels.trend,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="trend"
|
||||
filterParam="trend"
|
||||
description={municipalityColumnDescriptions.trend}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<MunicipalityListTrendControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
status={municipality.politicalTrendStatus}
|
||||
trendNote={municipality.politicalTrendNote}
|
||||
variant="popover"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'expectedVotes',
|
||||
label: municipalityColumnLabels.expectedVotes,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="expectedVotes"
|
||||
align="center"
|
||||
description={municipalityColumnDescriptions.expectedVotes}
|
||||
/>
|
||||
),
|
||||
cellClassName: 'relative overflow-visible align-middle text-center',
|
||||
cell: (municipality) => (
|
||||
<div className="flex min-h-11 items-center justify-center">
|
||||
<MunicipalityListExpectedVotesControl
|
||||
cell: (municipality) => <TerritorialClassReadout municipality={municipality} />,
|
||||
// The factors behind the label — the cell keeps them as `sr-only`
|
||||
// text, so this stays a redundant affordance (see `cellTooltip`).
|
||||
cellTooltip: (municipality) =>
|
||||
municipality.territorialClass === 'sem_base'
|
||||
? null
|
||||
: formatTerritorialClassWhy(municipality.territorialClassFactors),
|
||||
},
|
||||
{
|
||||
id: 'advisors',
|
||||
label: municipalityColumnLabels.advisors,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="coverage"
|
||||
filterParam="advisor"
|
||||
filterOptions={columnFilterOptions.advisor}
|
||||
description={municipalityColumnDescriptions.advisors}
|
||||
className={responsiveColumnClassName.advisors}
|
||||
/>
|
||||
),
|
||||
cellClassName: responsiveColumnClassName.advisors,
|
||||
cell: (municipality) =>
|
||||
isCoordinator ? (
|
||||
<MunicipalityListAdvisorsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
currentAdvisorIDs={municipality.advisorIDs}
|
||||
isPriority={municipality.priority === 'alta'}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
variant="popover"
|
||||
/>
|
||||
) : (
|
||||
<MunicipalityAdvisorAvatarStack
|
||||
advisors={advisorEntries(municipality, advisorNamesById)}
|
||||
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',
|
||||
label: municipalityColumnLabels.trend,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="trend"
|
||||
filterParam="trend"
|
||||
description={municipalityColumnDescriptions.trend}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) => renderTrendControl(municipality, 'adaptive'),
|
||||
},
|
||||
{
|
||||
id: 'leaderships',
|
||||
label: municipalityColumnLabels.leaderships,
|
||||
head: (
|
||||
<CampaignTableHead
|
||||
description={municipalityColumnDescriptions.leaderships}
|
||||
className={responsiveColumnClassName.leaderships}
|
||||
>
|
||||
{municipalityColumnLabels.leaderships}
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: responsiveColumnClassName.leaderships,
|
||||
cell: (municipality) => (
|
||||
<MunicipalityListLeadershipsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
expectedVotes={municipality.expectedVotes}
|
||||
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
|
||||
currentLeadershipIDs={municipality.leadershipIDs}
|
||||
leadershipNamesById={leadershipNamesById}
|
||||
options={leadershipOptions}
|
||||
variant="popover"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'lastSignal',
|
||||
label: municipalityColumnLabels.lastSignal,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="frescor"
|
||||
description={municipalityColumnDescriptions.lastSignal}
|
||||
>
|
||||
{/* The one header that does NOT inherit its sort label: the column
|
||||
shows the signal, `frescor` sorts by how old it is. */}
|
||||
{municipalityColumnLabels.lastSignal}
|
||||
</MunicipalitySortableHead>
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<MunicipalityListSignalControl
|
||||
municipalityID={municipality.id}
|
||||
municipalitySlug={municipality.slug}
|
||||
municipalityName={municipality.name}
|
||||
lastSignalAt={municipality.lastSignalAt}
|
||||
variant="popover"
|
||||
formAction={signalFormAction}
|
||||
>
|
||||
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="table" />
|
||||
</MunicipalityListSignalControl>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'goalCoverage',
|
||||
label: municipalityColumnLabels.goalCoverage,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="deficit"
|
||||
description={municipalityColumnDescriptions.goalCoverage}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<MunicipalityListGoalCoverageCell
|
||||
coverageByScenario={municipality.goalCoverageByScenario}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] satisfies Array<MunicipalityColumn>)
|
||||
: ([
|
||||
{
|
||||
id: 'lastUpdateAt',
|
||||
label: municipalityColumnLabels.lastUpdateAt,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="lastUpdateAt"
|
||||
description={municipalityColumnDescriptions.lastUpdateAt}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) =>
|
||||
municipality.lastUpdateAt
|
||||
? dateFormatter.format(new Date(municipality.lastUpdateAt))
|
||||
: 'Sem atualização',
|
||||
},
|
||||
] satisfies Array<MunicipalityColumn>)),
|
||||
]
|
||||
),
|
||||
},
|
||||
...(isCampaignUnrestricted
|
||||
? ([
|
||||
{
|
||||
id: 'stateDeputies',
|
||||
label: municipalityColumnLabels.stateDeputies,
|
||||
head: (
|
||||
<CampaignTableHead
|
||||
description={municipalityColumnDescriptions.stateDeputies}
|
||||
className={responsiveColumnClassName.stateDeputies}
|
||||
>
|
||||
{municipalityColumnLabels.stateDeputies}
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: cn(
|
||||
responsiveColumnClassName.stateDeputies,
|
||||
'max-w-56 whitespace-normal',
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<MunicipalityStateDeputyRelationCell
|
||||
municipalityId={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
stateDeputyIDs={municipality.stateDeputyIDs}
|
||||
options={stateDeputyOptions}
|
||||
commitAction={stateDeputyCommitAction}
|
||||
createAction={stateDeputyCreateAction}
|
||||
editorVariant="popover"
|
||||
/>
|
||||
),
|
||||
},
|
||||
] satisfies Array<MunicipalityColumn>)
|
||||
: []),
|
||||
|
||||
{
|
||||
id: 'goalCoverage',
|
||||
label: municipalityColumnLabels.goalCoverage,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="deficit"
|
||||
description={municipalityColumnDescriptions.goalCoverage}
|
||||
className={responsiveColumnClassName.goalCoverage}
|
||||
/>
|
||||
),
|
||||
cellClassName: responsiveColumnClassName.goalCoverage,
|
||||
cell: (municipality) => (
|
||||
<MunicipalityListGoalCoverageCell
|
||||
coverageByScenario={municipality.goalCoverageByScenario}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'lastSignal',
|
||||
label: municipalityColumnLabels.lastSignal,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="frescor"
|
||||
description={municipalityColumnDescriptions.lastSignal}
|
||||
>
|
||||
{municipalityColumnLabels.lastSignal}
|
||||
</MunicipalitySortableHead>
|
||||
),
|
||||
cell: (municipality) => renderSignalControl(municipality, 'adaptive'),
|
||||
},
|
||||
...(trendIsHidden || signalIsHidden
|
||||
? ([
|
||||
{
|
||||
id: 'actions',
|
||||
label: 'Ações',
|
||||
mandatory: true,
|
||||
head: (
|
||||
<CampaignTableHead align="right">
|
||||
<span className="sr-only">Ações</span>
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: 'text-right',
|
||||
cell: (municipality) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{trendIsHidden ? renderTrendControl(municipality, 'compact') : null}
|
||||
{signalIsHidden ? renderSignalControl(municipality, 'compact') : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] satisfies Array<MunicipalityColumn>)
|
||||
: []),
|
||||
] satisfies Array<MunicipalityColumn>)
|
||||
: ([
|
||||
{
|
||||
id: 'lastUpdateAt',
|
||||
label: municipalityColumnLabels.lastUpdateAt,
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="lastUpdateAt"
|
||||
description={municipalityColumnDescriptions.lastUpdateAt}
|
||||
/>
|
||||
),
|
||||
cell: (municipality) =>
|
||||
municipality.lastUpdateAt
|
||||
? dateFormatter.format(new Date(municipality.lastUpdateAt))
|
||||
: 'Sem atualização',
|
||||
},
|
||||
] satisfies Array<MunicipalityColumn>)),
|
||||
]
|
||||
}
|
||||
|
||||
export const municipalityListPickerColumns = ({
|
||||
isStaffView,
|
||||
|
|
@ -504,25 +595,23 @@ export const municipalityListPickerColumns = ({
|
|||
>): CampaignColumnPickerColumn[] => {
|
||||
const base: CampaignColumnPickerColumn[] = [
|
||||
{ id: 'name', label: municipalityColumnLabels.name, mandatory: true },
|
||||
{ id: 'region', label: municipalityColumnLabels.region },
|
||||
{ id: 'votos', label: municipalityColumnLabels.votos },
|
||||
]
|
||||
if (!isStaffView) return base
|
||||
|
||||
return [
|
||||
...base,
|
||||
{ id: 'classe', label: municipalityColumnLabels.classe },
|
||||
{ id: 'expectedVotes', label: municipalityColumnLabels.expectedVotes },
|
||||
{ id: 'level', label: municipalityColumnLabels.level },
|
||||
{ id: 'classe', label: municipalityColumnLabels.classe },
|
||||
{ id: 'advisors', label: municipalityColumnLabels.advisors },
|
||||
{ id: 'trend', label: municipalityColumnLabels.trend },
|
||||
{ id: 'leaderships', label: municipalityColumnLabels.leaderships },
|
||||
...(isCampaignUnrestricted
|
||||
? [{ id: 'stateDeputies', label: municipalityColumnLabels.stateDeputies }]
|
||||
: []),
|
||||
{ id: 'trend', label: municipalityColumnLabels.trend },
|
||||
{ id: 'expectedVotes', label: municipalityColumnLabels.expectedVotes },
|
||||
{ id: 'lastSignal', label: municipalityColumnLabels.lastSignal },
|
||||
{ id: 'goalCoverage', label: municipalityColumnLabels.goalCoverage },
|
||||
{ id: 'lastUpdateAt', label: municipalityColumnLabels.lastUpdateAt },
|
||||
{ id: 'lastSignal', label: municipalityColumnLabels.lastSignal },
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -554,40 +643,42 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
|
|||
return (
|
||||
<MunicipalityAdvisorCreateProvider>
|
||||
<MunicipalityLeadershipCreateProvider>
|
||||
<MunicipalityListMobileSection
|
||||
municipalities={municipalities}
|
||||
advisorNamesById={advisorNamesById}
|
||||
leadershipNamesById={leadershipNamesById}
|
||||
isStaffView={isStaffView}
|
||||
isCoordinator={isCoordinator}
|
||||
isCampaignUnrestricted={isCampaignUnrestricted}
|
||||
canMoveEngagementLevel={props.canMoveEngagementLevel}
|
||||
advisorOptions={advisorOptions}
|
||||
leadershipOptions={leadershipOptions}
|
||||
stateDeputyOptions={stateDeputyOptions}
|
||||
stateDeputyCommitAction={stateDeputyCommitAction}
|
||||
stateDeputyCreateAction={stateDeputyCreateAction}
|
||||
signalFormAction={props.signalFormAction}
|
||||
emptySlot={<MunicipalityListEmptyState state={state} />}
|
||||
/>
|
||||
<div data-container="municipality-list" className="@container/municipality-list">
|
||||
<MunicipalityListMobileSection
|
||||
municipalities={municipalities}
|
||||
advisorNamesById={advisorNamesById}
|
||||
leadershipNamesById={leadershipNamesById}
|
||||
isStaffView={isStaffView}
|
||||
isCoordinator={isCoordinator}
|
||||
isCampaignUnrestricted={isCampaignUnrestricted}
|
||||
canMoveEngagementLevel={props.canMoveEngagementLevel}
|
||||
advisorOptions={advisorOptions}
|
||||
leadershipOptions={leadershipOptions}
|
||||
stateDeputyOptions={stateDeputyOptions}
|
||||
stateDeputyCommitAction={stateDeputyCommitAction}
|
||||
stateDeputyCreateAction={stateDeputyCreateAction}
|
||||
signalFormAction={props.signalFormAction}
|
||||
emptySlot={<MunicipalityListEmptyState state={state} />}
|
||||
/>
|
||||
|
||||
{/* A sticky <th> can't paint the row border, hence the inset shadow. */}
|
||||
<CampaignTable
|
||||
className="hidden overflow-visible md:block"
|
||||
containerClassName="overflow-x-auto"
|
||||
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
|
||||
caption={
|
||||
<>
|
||||
{sortSummary}
|
||||
{showsVoteColumn ? `. Coluna 2022: ${municipalityColumnDescriptions.votos}` : null}
|
||||
</>
|
||||
}
|
||||
columns={columns}
|
||||
columnVisibility={props.columnVisibility}
|
||||
rows={municipalities}
|
||||
rowKey={(municipality) => municipality.id}
|
||||
empty={<MunicipalityListEmptyState state={state} />}
|
||||
/>
|
||||
{/* A sticky <th> can't paint the row border, hence the inset shadow. */}
|
||||
<CampaignTable
|
||||
className="hidden overflow-visible @min-[48rem]/municipality-list:block"
|
||||
containerClassName="overflow-x-hidden"
|
||||
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
|
||||
caption={
|
||||
<>
|
||||
{sortSummary}
|
||||
{showsVoteColumn ? `. Coluna 2022: ${municipalityColumnDescriptions.votos}` : null}
|
||||
</>
|
||||
}
|
||||
columns={columns}
|
||||
columnVisibility={props.columnVisibility}
|
||||
rows={municipalities}
|
||||
rowKey={(municipality) => municipality.id}
|
||||
empty={<MunicipalityListEmptyState state={state} />}
|
||||
/>
|
||||
</div>
|
||||
</MunicipalityLeadershipCreateProvider>
|
||||
</MunicipalityAdvisorCreateProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
<MunicipalityLevelBadge
|
||||
level={currentLevel}
|
||||
note={isSheet ? currentNote : null}
|
||||
layout={isSheet ? 'card' : '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 ? (
|
||||
<MunicipalityLevelBadge
|
||||
level={currentLevel}
|
||||
note={isSheet ? currentNote : null}
|
||||
layout={isSheet ? 'card' : 'table'}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
|
|
|
|||
|
|
@ -83,7 +83,10 @@ export const MunicipalityListMobileCards = ({
|
|||
signalFormAction,
|
||||
emptySlot,
|
||||
}: MunicipalityListMobileCardsProps) => (
|
||||
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
|
||||
<div
|
||||
data-view="mobile-cards"
|
||||
className="flex flex-col gap-4 @min-[48rem]/municipality-list:hidden"
|
||||
>
|
||||
{municipalities.length === 0 ? emptySlot : null}
|
||||
{municipalities.map((municipality) => {
|
||||
const names = advisorEntriesFromIds(municipality.advisorIDs, advisorNamesById).map(
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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 = <Badge variant={badgeVariant}>{trendLabel}</Badge>
|
||||
const compactTrigger = (
|
||||
<Badge variant={badgeVariant} className="size-7 justify-center p-0">
|
||||
<TrendIcon className="size-4" aria-hidden="true" />
|
||||
</Badge>
|
||||
)
|
||||
const trigger =
|
||||
triggerPresentation === 'full' ? (
|
||||
fullTrigger
|
||||
) : triggerPresentation === 'compact' ? (
|
||||
compactTrigger
|
||||
) : (
|
||||
<>
|
||||
<span className="@min-[60rem]/municipality-list:hidden">{compactTrigger}</span>
|
||||
<span className="hidden @min-[60rem]/municipality-list:inline-flex">{fullTrigger}</span>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<CampaignCellEditOverlay
|
||||
|
|
@ -102,17 +130,12 @@ export const MunicipalityListTrendControl = ({
|
|||
// `MunicipalityListSignalControl`'s.
|
||||
triggerLabel={`Editar tendência política em ${municipalityName} — ${trendLabel}`}
|
||||
triggerBusy={isPending}
|
||||
triggerClassName={triggerPresentation === 'full' ? undefined : 'min-w-11'}
|
||||
statusMessage={statusMessage}
|
||||
tooltipContent={hasNote ? <p className="whitespace-pre-wrap">{value.note}</p> : null}
|
||||
contentClassName="w-72 p-3"
|
||||
preventPopoverAutoFocus
|
||||
trigger={
|
||||
value.status ? (
|
||||
<Badge variant={politicalTrendBadgeVariant[value.status]}>{trendLabel}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{trendLabel}</Badge>
|
||||
)
|
||||
}
|
||||
trigger={trigger}
|
||||
>
|
||||
<div className="relative flex flex-col gap-3">
|
||||
{isPending ? (
|
||||
|
|
|
|||
|
|
@ -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<MunicipalityListColumnId, string> = {
|
||||
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<MunicipalityListColumnId, st
|
|||
stateDeputies:
|
||||
'Deputados estaduais com quem a campanha dobra neste município — coordenação e candidato editam clicando aqui.',
|
||||
trend: 'Tendência política percebida pela equipe: favorável, neutra ou desfavorável.',
|
||||
expectedVotes: campaignConceptOneLiner('meta'),
|
||||
expectedVotes:
|
||||
'Estimativa de votos do candidato neste município em 2026. Ordena pelo cenário central, independente do cenário selecionado acima.',
|
||||
lastSignal: `Última atualização da equipe ou declaração de liderança, o que for mais recente. Fica destacado a partir de ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias sem registro.`,
|
||||
// The scenario picker above the table is client state, so the server can
|
||||
// only sort by one scenario — named here so the ordering never looks
|
||||
|
|
|
|||
|
|
@ -101,21 +101,18 @@ export const municipalityListSortLabels: Record<MunicipalityListSortKey, string>
|
|||
*/
|
||||
export const municipalityColumnLabels: Record<MunicipalityListColumnId, string> = {
|
||||
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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 levelHeader = page.getByRole('columnheader', { name: /Nível/ })
|
||||
await expect(levelHeader).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: 'Nível' }).click()
|
||||
// Closing flushes the batched cookie write and refreshes the RSC.
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await expect(regionHeader).toHaveCount(0, REFRESH)
|
||||
await expect(levelHeader).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: /Nível/ })).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: /Nível/ })).toBeVisible(REFRESH)
|
||||
await page.reload()
|
||||
await expect(page.getByRole('columnheader', { name: /Nível/ })).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 }) => {
|
||||
|
|
|
|||
223
tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts
Normal file
223
tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
const WEBKIT_PROJECT = 'municipality-responsive-webkit'
|
||||
const REM_IN_PIXELS = 16
|
||||
const municipalityColumnsCookie = encodeURIComponent('municipios:__none__')
|
||||
|
||||
const municipalityContainer = (page: Page) => page.locator('[data-container="municipality-list"]')
|
||||
|
||||
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) => ({
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
})),
|
||||
)
|
||||
.toMatchObject({ clientWidth: expect.any(Number) })
|
||||
const dimensions = await container.evaluate((element) => ({
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
}))
|
||||
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth)
|
||||
}
|
||||
|
||||
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<void>
|
||||
}
|
||||
|
||||
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 expect(municipalityContainer(page)).toBeVisible()
|
||||
await page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
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,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name === WEBKIT_PROJECT, 'Covered by the focused iPad WebKit case.')
|
||||
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()
|
||||
const widths = [
|
||||
48 * REM_IN_PIXELS - 1,
|
||||
48 * REM_IN_PIXELS + 1,
|
||||
54 * REM_IN_PIXELS - 1,
|
||||
54 * REM_IN_PIXELS + 1,
|
||||
60 * REM_IN_PIXELS - 1,
|
||||
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 + 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,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name === WEBKIT_PROJECT, 'Desktop shell behavior is Chromium-only.')
|
||||
test.slow()
|
||||
|
||||
await page.setViewportSize({ width: 1800, 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)
|
||||
const headersWithoutSidebar = await visibleHeaders(container)
|
||||
expect(headersWithoutSidebar.length).toBeGreaterThan(headersWithSidebar.length)
|
||||
|
||||
await page.getByRole('button', { name: 'Sollinha — Assistente virtual' }).first().click()
|
||||
const widthWithChat = await container.evaluate((element) => element.clientWidth)
|
||||
const headersWithChat = await visibleHeaders(container)
|
||||
await page.getByRole('button', { name: 'Fechar' }).last().click()
|
||||
await expect
|
||||
.poll(() => container.evaluate((element) => element.clientWidth))
|
||||
.toBeGreaterThan(widthWithChat)
|
||||
expect((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,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name === WEBKIT_PROJECT, 'Role coverage runs once in Chromium.')
|
||||
|
||||
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 iPad-sized WebKit', async ({
|
||||
campaign,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== WEBKIT_PROJECT, 'Focused WebKit coverage only.')
|
||||
|
||||
await loginWithAllColumns(page, campaign)
|
||||
const container = municipalityContainer(page)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<MunicipalityListViewModel>({
|
||||
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<Parameters<typeof AppRouterContext.Provider>[0]['value']>({
|
||||
push: () => {},
|
||||
replace: () => {},
|
||||
|
|
@ -279,15 +310,22 @@ 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 `<thead>` itself, since `cellTooltip` (e.g. "Classe") and
|
||||
// the mobile card's own class tooltip (B42) add triggers of their own.
|
||||
const [, tbodyHtml = ''] = html.split('<tbody')
|
||||
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
|
||||
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('overflow-x-hidden')
|
||||
expect(html).not.toContain('overflow-x-auto')
|
||||
expect(theadHtml).toContain('Município')
|
||||
expect(tbodyHtml).toContain('Seabra')
|
||||
})
|
||||
|
|
@ -338,11 +376,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('<thead'), html.indexOf('</thead>'))
|
||||
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 +700,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('<tbody')
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(4)
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(3)
|
||||
})
|
||||
|
||||
describe('B158 responsive municipality columns', () => {
|
||||
const renderResponsiveList = (hiddenColumnIds: string[] = []) =>
|
||||
renderWithAppRouter(
|
||||
createElement(MunicipalityList, {
|
||||
municipalities: [responsiveMunicipality],
|
||||
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
|
||||
...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('<thead'), html.indexOf('</thead>'))
|
||||
const tbodyHtml = html.slice(html.indexOf('<tbody'), html.indexOf('</tbody>'))
|
||||
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('<td')[1] ?? ''
|
||||
expect(nameCellHtml).toContain('São Francisco do Conde com nome extenso')
|
||||
expect(nameCellHtml).toContain('Metropolitano de Salvador')
|
||||
expect(nameCellHtml).toContain('href="/campanha/territorios#ti-metropolitano-de-salvador"')
|
||||
|
||||
for (const className of [
|
||||
'hidden @min-[54rem]/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',
|
||||
]) {
|
||||
expect(html.split(className)).toHaveLength(3)
|
||||
}
|
||||
expect(tbodyHtml).toContain('@min-[60rem]/municipality-list:hidden')
|
||||
expect(tbodyHtml).toContain('@min-[60rem]/municipality-list:inline-flex')
|
||||
expect(tbodyHtml).toContain('@min-[84rem]/municipality-list:hidden')
|
||||
expect(tbodyHtml).toContain('@min-[84rem]/municipality-list:block')
|
||||
|
||||
const firstRowCells = tbodyHtml.split('<td').slice(1)
|
||||
expect(firstRowCells[3]?.split('</td>')[0]).toContain('>—</span>')
|
||||
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('<table'), html.indexOf('</table>'))
|
||||
const theadHtml = tableHtml.slice(tableHtml.indexOf('<thead'), tableHtml.indexOf('</thead>'))
|
||||
|
||||
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 +835,10 @@ describe('campaign visual foundation', () => {
|
|||
})
|
||||
|
||||
it('wraps the advisor cell in a tooltip only when there is a name to show', () => {
|
||||
// The staff `<thead>` already carries 11 header-explanation tooltips
|
||||
// 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 = 11
|
||||
const HEADER_TOOLTIP_COUNT = 10
|
||||
const baseMunicipality = {
|
||||
id: 1,
|
||||
name: 'Seabra',
|
||||
|
|
|
|||
Loading…
Reference in a new issue