The picker shipped as a capability of CampaignTable rather than a municípios feature, so every list that already uses the shared table got it: pass `columnVisibility` and the table mounts the menu and filters the columns. Persistence is a cookie, not localStorage, and the reason is a type rather than a preference: CampaignTableColumn.cell is a function, so the column array never crosses the server/client boundary and the client cannot rebuild the table without it. The server filters, so a hidden column does not reach the RSC payload either. `campaign_columns` carries a listId -> hidden ids map in a compact form with no JSON and no percent-encoding, because document.cookie and Next's cookies() disagree about decoding. Closing the menu is the commit. The first cut debounced at 400 ms, which batches a burst rather than a reading pace, so unchecking four columns while reading their labels was four full-route refreshes — on /campanha/liderancas each one re-pays the ~229 ms / 371-statement render already ledgered P1. The timer survives at 3 s as a safety net for a menu left open; the price is that the table behind the menu no longer updates per checkbox. `defaultVisible` is gone: it was true on 10 columns and false on none, and storing only the hidden set is what makes a newly shipped column visible to everyone who never touched the picker. In exchange `label` is required, which made `head` optional — 24 columns had a head that only restated the new label. Each surface declares one mandatory column, re-filtered on the server so a stale cookie cannot leave a row without its only linked cell. The bug review caught is this codebase's recurring one: the RSC payload of toggle A arrives inside the window of toggle B, and adopting the server there undid B and then committed the set without it. Reconciliation now runs only when no write is pending, pinned red against the previous code. Municipality column ids are typed against MunicipalityListColumnId now, so the id, the B22 description and the picker label are one key; the union member `nivel` was renamed `level` to match the id and the ?level= filter. The picker's own content comparison is sameIdSet, widened to string ids.
126 lines
4.3 KiB
TypeScript
126 lines
4.3 KiB
TypeScript
import { createElement } from 'react'
|
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
import { CampaignTable, CampaignTableHead } from '@/components/campaign/shared/CampaignTable'
|
|
|
|
type Row = { id: number; name: string; note: string | null }
|
|
|
|
const rows: Row[] = [
|
|
{ id: 1, name: 'Seabra', note: 'Vereador migrou para a base' },
|
|
{ id: 2, name: 'Itaberaba', note: null },
|
|
]
|
|
|
|
const renderTable = (cellTooltip?: (row: Row) => string | null) =>
|
|
renderToStaticMarkup(
|
|
createElement(CampaignTable<Row>, {
|
|
columns: [
|
|
{
|
|
id: 'name',
|
|
label: 'Município',
|
|
cell: (row) => row.name,
|
|
cellTooltip,
|
|
},
|
|
],
|
|
rows,
|
|
rowKey: (row) => row.id,
|
|
}),
|
|
)
|
|
|
|
/**
|
|
* B17: `label` is what the column picker shows, and it is the header too
|
|
* unless the column asks for more. Declaring both is how the two drift.
|
|
*/
|
|
describe('CampaignTable head', () => {
|
|
it('builds the header from the label when the column declares no head', () => {
|
|
const html = renderTable()
|
|
|
|
expect(html).toContain('>Município<')
|
|
expect(html.match(/<th[\s>]/g)).toHaveLength(1)
|
|
})
|
|
|
|
it('lets a column own its header when it needs more than its name', () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(CampaignTable<Row>, {
|
|
columns: [
|
|
{
|
|
id: 'name',
|
|
label: 'Município',
|
|
head: createElement(CampaignTableHead, { align: 'right' }, 'Município'),
|
|
cell: (row) => row.name,
|
|
},
|
|
],
|
|
rows,
|
|
rowKey: (row) => row.id,
|
|
}),
|
|
)
|
|
|
|
expect(html).toContain('text-right')
|
|
})
|
|
})
|
|
|
|
/**
|
|
* B23 seam consumed by E10's "Classe" column: the tooltip is declared by the
|
|
* column, rendered by the table. These pin the two halves of the contract that
|
|
* a consumer would otherwise have to rediscover.
|
|
*/
|
|
describe('CampaignTable cellTooltip', () => {
|
|
it('renders the cell untouched when a column declares no tooltip', () => {
|
|
const html = renderTable()
|
|
|
|
expect(html).toContain('Seabra')
|
|
// No wrapper at all: the municipality list uses overflow-x-auto + sticky
|
|
// header/first column, so an unrequested inline-flex span is a layout risk.
|
|
expect(html).not.toContain('inline-flex')
|
|
})
|
|
|
|
it('wraps only the rows whose tooltip has content', () => {
|
|
const html = renderTable((row) => row.note)
|
|
|
|
// Itaberaba has no note, so it must render exactly like the no-tooltip
|
|
// case — one trigger in the table, not two.
|
|
expect(html.match(/data-slot="tooltip-trigger"/g)).toHaveLength(1)
|
|
expect(html).toContain('>Itaberaba<')
|
|
})
|
|
|
|
/**
|
|
* Radix mounts tooltip content only once opened, so the reason never reaches
|
|
* the server markup. That is precisely why `cellTooltip`'s contract demands
|
|
* the same information already exist as text in the cell — a column that
|
|
* moves its only copy into the tooltip silently loses it for assistive tech.
|
|
*/
|
|
it('keeps the tooltip content out of the server markup', () => {
|
|
expect(renderTable((row) => row.note)).not.toContain('Vereador migrou para a base')
|
|
})
|
|
})
|
|
|
|
/**
|
|
* B22 seam: a plain header can carry the same redundant hover/focus/tap
|
|
* explanation `cellTooltip` gives a cell. Without `description` the render is
|
|
* byte-for-byte what it was before B22 — no new wrapper, no new tab stop.
|
|
*/
|
|
describe('CampaignTableHead description', () => {
|
|
it('renders unchanged when no description is declared', () => {
|
|
const html = renderToStaticMarkup(createElement(CampaignTableHead, {}, 'Território'))
|
|
|
|
expect(html).toContain('Território')
|
|
expect(html).not.toContain('data-slot="tooltip-trigger"')
|
|
expect(html).not.toContain('tabindex')
|
|
})
|
|
|
|
it('wraps the label in exactly one tooltip trigger when description is declared', () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(
|
|
CampaignTableHead,
|
|
{ description: 'Território de Identidade a que o município pertence.' },
|
|
'Território',
|
|
),
|
|
)
|
|
|
|
expect(html.match(/data-slot="tooltip-trigger"/g)).toHaveLength(1)
|
|
expect(html).toContain('>Território<')
|
|
// Same contract as cellTooltip: Radix only mounts content once opened, so
|
|
// the description text never reaches the server markup.
|
|
expect(html).not.toContain('Território de Identidade a que o município pertence.')
|
|
})
|
|
})
|