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.
211 lines
7.5 KiB
TypeScript
211 lines
7.5 KiB
TypeScript
'use client'
|
|
|
|
import { Columns3Icon } from 'lucide-react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
|
|
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Checkbox } from '@/components/ui/Checkbox'
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
|
import {
|
|
CAMPAIGN_COLUMNS_COOKIE,
|
|
CAMPAIGN_COLUMNS_COOKIE_MAX_AGE,
|
|
CAMPAIGN_COLUMNS_COOKIE_PATH,
|
|
parseCampaignHiddenColumns,
|
|
serializeCampaignHiddenColumns,
|
|
toggleHiddenColumn,
|
|
type CampaignListId,
|
|
} from '@/lib/campaignColumnVisibility'
|
|
import { sameIdSet } from '@/lib/sameIdSet'
|
|
|
|
type CampaignColumnPickerColumn = {
|
|
id: string
|
|
label: string
|
|
mandatory?: boolean
|
|
}
|
|
|
|
type CampaignColumnPickerProps = {
|
|
listId: CampaignListId
|
|
/** Every column of the table, hidden ones included — this is the menu. */
|
|
columns: readonly CampaignColumnPickerColumn[]
|
|
/** Server truth: what this device has hidden on this list. */
|
|
hiddenColumnIds: readonly string[]
|
|
}
|
|
|
|
/**
|
|
* Closing the menu is what commits — an editing session is one navigation, not
|
|
* one per checkbox. A 400 ms debounce only ever batched clicks faster than
|
|
* reading the label between them, and each miss re-runs the whole route: on
|
|
* `/campanha/liderancas` that is the ~229 ms / 371-statement render ledgered as
|
|
* P1. This timer is the safety net for a menu left open, not the commit path.
|
|
* It reads a ref, never the state of the render that scheduled it — the
|
|
* `setTimeout`-captures-stale-state bug B33+ pinned across the list filters.
|
|
*/
|
|
const COMMIT_IDLE_MS = 3_000
|
|
|
|
const readCookie = () =>
|
|
parseCampaignHiddenColumns(
|
|
document.cookie
|
|
.split('; ')
|
|
.find((entry) => entry.startsWith(`${CAMPAIGN_COLUMNS_COOKIE}=`))
|
|
?.slice(CAMPAIGN_COLUMNS_COOKIE.length + 1),
|
|
)
|
|
|
|
const writeCookie = (listId: CampaignListId, hiddenColumnIds: readonly string[]) => {
|
|
// Re-read before writing: one cookie carries every list, and another tab may
|
|
// have changed a different one since this page rendered.
|
|
const next = { ...readCookie(), [listId]: [...hiddenColumnIds] }
|
|
|
|
document.cookie = [
|
|
`${CAMPAIGN_COLUMNS_COOKIE}=${serializeCampaignHiddenColumns(next)}`,
|
|
`path=${CAMPAIGN_COLUMNS_COOKIE_PATH}`,
|
|
`max-age=${CAMPAIGN_COLUMNS_COOKIE_MAX_AGE}`,
|
|
'samesite=lax',
|
|
// Matches `campaignCookieOptions`; keyed off the protocol rather than
|
|
// NODE_ENV because `secure` on plain http silently drops the write.
|
|
...(window.location.protocol === 'https:' ? ['secure'] : []),
|
|
].join('; ')
|
|
}
|
|
|
|
export const CampaignColumnPicker = ({
|
|
listId,
|
|
columns,
|
|
hiddenColumnIds,
|
|
}: CampaignColumnPickerProps) => {
|
|
const router = useRouter()
|
|
// Only the transition — the busy state belongs to `CampaignListResults`,
|
|
// which wraps this table and already dims and marks itself `aria-busy`.
|
|
const { startTransition } = useCampaignListTransition()
|
|
const [open, setOpen] = useState(false)
|
|
const [hidden, setHidden] = useState<string[]>(() => [...hiddenColumnIds])
|
|
const [serverIds, setServerIds] = useState<readonly string[]>(hiddenColumnIds)
|
|
const latestHidden = useRef(hidden)
|
|
const commitTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
// The server is the source of truth: once its payload catches up, it wins.
|
|
// But never over an edit that has not been written yet — the payload of
|
|
// toggle A lands while B is still in the debounce window, and adopting it
|
|
// there would roll B back and then commit the rolled-back set, losing it.
|
|
// Same policy as `useCampaignListFilterNavigation`, where a pending debounce
|
|
// outranks the URL; `sameIdSet` is the shared "did the server really change".
|
|
if (!sameIdSet(serverIds, hiddenColumnIds) && commitTimer.current === null) {
|
|
setServerIds(hiddenColumnIds)
|
|
setHidden([...hiddenColumnIds])
|
|
latestHidden.current = [...hiddenColumnIds]
|
|
}
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (commitTimer.current) clearTimeout(commitTimer.current)
|
|
},
|
|
[],
|
|
)
|
|
|
|
const commit = () => {
|
|
if (commitTimer.current) {
|
|
clearTimeout(commitTimer.current)
|
|
commitTimer.current = null
|
|
}
|
|
writeCookie(listId, latestHidden.current)
|
|
startTransition(() => {
|
|
router.refresh()
|
|
})
|
|
}
|
|
|
|
const apply = (next: string[]) => {
|
|
// Advance the ref synchronously: two toggles batched into one tick must not
|
|
// drop each other, and the debounced commit reads the ref.
|
|
latestHidden.current = next
|
|
setHidden(next)
|
|
|
|
if (commitTimer.current) clearTimeout(commitTimer.current)
|
|
commitTimer.current = setTimeout(commit, COMMIT_IDLE_MS)
|
|
}
|
|
|
|
const toggle = (columnId: string, visible: boolean) => {
|
|
apply(toggleHiddenColumn(latestHidden.current, columnId, visible))
|
|
}
|
|
|
|
const hiddenSet = new Set(hidden)
|
|
const hiddenCount = columns.filter(
|
|
(column) => !column.mandatory && hiddenSet.has(column.id),
|
|
).length
|
|
const headingId = `${listId}-column-picker`
|
|
|
|
return (
|
|
<Popover
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
setOpen(next)
|
|
// Closing is the commit: the whole session lands in one refresh.
|
|
if (!next && commitTimer.current) commit()
|
|
}}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="lg"
|
|
aria-label={
|
|
hiddenCount
|
|
? `Mostrar ou ocultar colunas. ${hiddenCount} oculta${hiddenCount > 1 ? 's' : ''}`
|
|
: 'Mostrar ou ocultar colunas'
|
|
}
|
|
>
|
|
<Columns3Icon aria-hidden="true" />
|
|
Colunas
|
|
{hiddenCount ? (
|
|
<span className="text-muted-foreground tabular-nums">({hiddenCount})</span>
|
|
) : null}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
{/* Radix gives the content `role="dialog"` and no name — same reason
|
|
`CampaignCellEditOverlay` labels its own. */}
|
|
<PopoverContent align="end" aria-label="Mostrar colunas" className="w-64 p-2">
|
|
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
|
<p id={headingId} className="text-xs font-medium text-muted-foreground">
|
|
Mostrar colunas
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="xs"
|
|
disabled={hiddenCount === 0}
|
|
onClick={() => apply([])}
|
|
>
|
|
Restaurar todas
|
|
</Button>
|
|
</div>
|
|
<div
|
|
role="group"
|
|
aria-labelledby={headingId}
|
|
className="flex max-h-80 flex-col gap-0.5 overflow-y-auto"
|
|
>
|
|
{columns.map((column) => {
|
|
const checkboxId = `${listId}-column-${column.id}`
|
|
|
|
return (
|
|
<label
|
|
key={column.id}
|
|
htmlFor={checkboxId}
|
|
className="flex min-h-11 items-center gap-2 rounded-md px-2 text-sm hover:bg-muted has-disabled:hover:bg-transparent"
|
|
>
|
|
<Checkbox
|
|
id={checkboxId}
|
|
checked={column.mandatory || !hiddenSet.has(column.id)}
|
|
disabled={column.mandatory}
|
|
onCheckedChange={(next) => toggle(column.id, next === true)}
|
|
/>
|
|
<span className="min-w-0 flex-1 whitespace-normal">{column.label}</span>
|
|
{column.mandatory ? (
|
|
<span className="text-xs text-muted-foreground">sempre visível</span>
|
|
) : null}
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|