Merge branch 'cursor/5ea2b864'
This commit is contained in:
commit
ead5358847
9 changed files with 377 additions and 34 deletions
|
|
@ -27,7 +27,7 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se
|
|||
## Sistema de listas (Pass 2 W1 — use, não reinvente)
|
||||
|
||||
- URL: `utilities/campaignListUrl.ts` (parse/canonicalize/`resolveListUrl`) + módulo por domínio (`municipalityListUrl.ts`, `supporterUi.ts`, `activityUi.ts`, parsers nos `*Data.ts`). Contrato de URL congelado (B18).
|
||||
- Tabela: `shared/CampaignTable.tsx` — colunas como dado (`id`, `label` **obrigatório**, `head` opcional (default = `<CampaignTableHead>{label}</CampaignTableHead>`; declare só para alinhamento, `description` do B22 ou ilha de head), `cell`, `mandatory`); heads ricos = ilhas (`municipality/MunicipalitySortableHead` + `MunicipalityHeaderFilter`). Seletor de colunas (B17): passe `columnVisibility` — **um** valor, devolvido por `readCampaignColumnVisibility(listId)` (`utilities/campaignColumnVisibilityCookie.ts`, `server-only`) em quem renderiza a tabela — e ela monta o `CampaignColumnPicker` e filtra por `resolveVisibleColumns`; regra pura em `lib/campaignColumnVisibility.ts`. Só o conjunto de **ocultas** é guardado (`defaultVisible` não existe): coluna nova nasce visível.
|
||||
- Tabela: `shared/CampaignTable.tsx` — colunas como dado (`id`, `label` **obrigatório**, `head` opcional (default = `<CampaignTableHead>{label}</CampaignTableHead>`; declare só para alinhamento, `description` do B22 ou ilha de head), `cell`, `mandatory`); heads ricos = ilhas de domínio sobre `shared/CampaignSortableHead` + `shared/CampaignHeaderFilterPopover` (multi-seleção: opções marcadas sobem ao topo via `lib/listFilterOptions.ts` — snapshot na abertura; gate = `row.checkbox`; single-select intocado). Seletor de colunas (B17): passe `columnVisibility` — **um** valor, devolvido por `readCampaignColumnVisibility(listId)` (`utilities/campaignColumnVisibilityCookie.ts`, `server-only`) em quem renderiza a tabela — e ela monta o `CampaignColumnPicker` e filtra por `resolveVisibleColumns`; regra pura em `lib/campaignColumnVisibility.ts`. Só o conjunto de **ocultas** é guardado (`defaultVisible` não existe): coluna nova nasce visível.
|
||||
- Shells: `CampaignSearchForm`, `CampaignFilterChips`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPagination`, pending compartilhado em `CampaignListPending.tsx` (dima o resultado, não o controle).
|
||||
- Relação por chips numa célula: `shared/MunicipalityPortfolioCell` (B34) — sempre montada em `pointer-fine`, Drawer em `pointer-coarse`; costuras `minItems`/`maxItems`/`addableIds`/`draft`/`commitAction`. Serve lideranças e assessores; não escrever uma terceira. Lógica pura em `lib/municipalityPortfolio.ts`, índice em `utilities/municipalityPortfolioIndex.ts`.
|
||||
- Exceções documentadas: atividades = cards; `TerritoryOverviewTable` = sort no cliente; comparação de candidatos, LeaderContacts e preview de import ficam fora.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +1,18 @@
|
|||
# Opções selecionadas no topo nos filtros do header
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos — "Selecionados no topo nos filtros do header"; superfície de coordenação, encaixe em B16 ✓)
|
||||
Impeccable: B — encaixe no Popover de filtro já entregue (`MunicipalityHeaderFilter`) e no select mobile equivalente; sem rota nova, sem tela nova
|
||||
Appetite: ~0,25–0,5 dia eng; helper puro client-safe + consumo nos dois controles + unit test; sem migration, sem collection, sem Consent, sem server action
|
||||
Status: entregue (2026-07-28)
|
||||
Atualizado em: 2026-07-28
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins — "Selecionados no topo nos filtros do header"; ✓)
|
||||
Impeccable: B — craft → critique → polish no `CampaignHeaderFilterPopover` compartilhado; `harden`/`optimize` out
|
||||
Appetite: ~0,25–0,5 dia eng; helper puro client-safe + consumo nos shells compartilhados + unit tests; sem migration, sem collection, sem Consent, sem server action
|
||||
Responsável: —
|
||||
|
||||
## Revisão 2026-07-28 (as-built)
|
||||
|
||||
A premissa do plano de 2026-07-25 ("não extrair o head genérico — B21, 3º call site") **já tinha sido paga** por B21/B29/B33: `CampaignHeaderFilterPopover` e `CampaignMobileMultiFilterField` vivem em `shared/` com 4 consumidores de header e 7 montagens mobile. O comportamento entrou nesses **dois** arquivos e alcança municípios, lideranças, territórios e dobradinhas de uma vez — nenhum `*HeaderFilter.tsx` de domínio mudou.
|
||||
|
||||
Entrega: `src/lib/listFilterOptions.ts` (`orderFilterOptionsSelectedFirst`) + consumo no Popover (snapshot na abertura; ordem congelada; hairline entre grupos; divisor oculto durante busca) e no mobile (sem snapshot, reordena a cada abertura). Gate multi = `optionRows.every(row => row.checkbox)` (1:1 com multi-seleção hoje; single-select intocado). Specs: `listFilterOptions.unit.spec.ts` + `campaignHeaderFilterPopover.unit.spec.ts` (freeze verificado). Critique: P1 "hairline pula na busca" fechado no polish; P1 "desmarcado acima da linha" aceito (snapshot intencional). Sem migration/action/Consent.
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípio 2 — clareza sob pressão; princípio 8 — Feel the action) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · shells do sistema de listas do Pass 2 W1 (`CampaignTable`, `CampaignTransitionAnchor`, `CampaignListPendingBoundary`) · Popover shadcn já em uso.
|
||||
|
|
@ -105,6 +111,7 @@ Componentes:
|
|||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Prop explícita de modo multi no `CampaignHeaderFilterPopover`.** Hoje o gate é `optionRows.every(row => row.checkbox)` (1:1 com os quatro consumidores atuais). Revisitar se surgir single-select com checkbox ou um terceiro modo de seleção — aí trocar heurística por prop (`liftSelected` / `multi`).
|
||||
- **Aplicar a regra a `RelationMultiSelect` / comboboxes de formulário.** Revisitar quando houver relato de atrito de desmarcação em formulário (ex.: carteira de municípios em `/campanha/assessores`) — o padrão lá é chip + busca, não lista de checkboxes.
|
||||
- **Head de filtro genérico em `shared/`.** Gatilho herdado do B21: 3º call site (E12 ou outra lista com header rico).
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -7,6 +7,7 @@ import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignL
|
|||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { orderFilterOptionsSelectedFirst } from '@/lib/listFilterOptions'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
|
||||
|
|
@ -72,7 +73,22 @@ export const CampaignHeaderFilterPopover = ({
|
|||
}: CampaignHeaderFilterPopoverProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
/**
|
||||
* Selected values at open time. Governs presentation order only — checkboxes
|
||||
* still read live `row.selected`. Freezing order while open keeps rows from
|
||||
* sliding under the pointer when the optimistic selection flips (same race
|
||||
* the B16 "click that undoes itself" bug taught).
|
||||
*/
|
||||
const [selectionSnapshot, setSelectionSnapshot] = useState<readonly string[]>([])
|
||||
const searchable = optionRows.length >= SEARCHABLE_OPTION_THRESHOLD
|
||||
/**
|
||||
* Selected-first ordering is multi-select only. Today every multi-select
|
||||
* consumer marks every option row with `checkbox: true`, and every
|
||||
* single-select leaves it off — so this gate is 1:1 with selection mode
|
||||
* without a new prop. Reordering a single-select would push "Todas" under
|
||||
* the marked value, which the product rejected.
|
||||
*/
|
||||
const isMultiSelect = optionRows.length > 0 && optionRows.every((row) => row.checkbox)
|
||||
const searchIndex = useMemo(
|
||||
() =>
|
||||
optionRows.map((row) => ({
|
||||
|
|
@ -82,21 +98,38 @@ export const CampaignHeaderFilterPopover = ({
|
|||
[optionRows],
|
||||
)
|
||||
const needle = normalizeSearchPhrase(query)
|
||||
const visibleRows = needle
|
||||
const filteredRows = needle
|
||||
? searchIndex.filter((entry) => entry.haystack.includes(needle)).map((entry) => entry.row)
|
||||
: optionRows
|
||||
const { ordered: visibleRows, selectedCount } =
|
||||
open && isMultiSelect
|
||||
? orderFilterOptionsSelectedFirst(filteredRows, selectionSnapshot)
|
||||
: { ordered: filteredRows, selectedCount: 0 }
|
||||
// Hide the hairline while searching: filtering already reshapes the list,
|
||||
// and relocating the group boundary on every keystroke is spatial noise.
|
||||
// Order still follows the open-time snapshot among matches.
|
||||
const showSelectedDivider =
|
||||
isMultiSelect && !needle && selectedCount > 0 && selectedCount < visibleRows.length
|
||||
|
||||
const choose = (row: CampaignHeaderFilterRow) => {
|
||||
row.onChoose()
|
||||
if (closeOnChoose) setOpen(false)
|
||||
}
|
||||
|
||||
const renderRows = (rows: readonly CampaignHeaderFilterRow[]) =>
|
||||
rows.map((row) => <FilterRow key={row.value} row={row} onChoose={() => choose(row)} />)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next)
|
||||
if (!next) setQuery('')
|
||||
if (next) {
|
||||
setSelectionSnapshot(optionRows.filter((row) => row.selected).map((row) => row.value))
|
||||
return
|
||||
}
|
||||
setQuery('')
|
||||
setSelectionSnapshot([])
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
|
|
@ -158,9 +191,15 @@ export const CampaignHeaderFilterPopover = ({
|
|||
aria-labelledby={`${id}-heading`}
|
||||
className="flex max-h-72 flex-col gap-0.5 overflow-y-auto"
|
||||
>
|
||||
{visibleRows.map((row) => (
|
||||
<FilterRow key={row.value} row={row} onChoose={() => choose(row)} />
|
||||
))}
|
||||
{showSelectedDivider ? (
|
||||
<>
|
||||
{renderRows(visibleRows.slice(0, selectedCount))}
|
||||
<div className="my-1 border-t border-border" role="presentation" />
|
||||
{renderRows(visibleRows.slice(selectedCount))}
|
||||
</>
|
||||
) : (
|
||||
renderRows(visibleRows)
|
||||
)}
|
||||
{visibleRows.length === 0 ? (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">
|
||||
{needle ? 'Nenhum resultado encontrado.' : emptyLabel}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { orderFilterOptionsSelectedFirst } from '@/lib/listFilterOptions'
|
||||
|
||||
type CampaignMobileMultiFilterOption = {
|
||||
value: string
|
||||
|
|
@ -22,25 +23,32 @@ export const CampaignMobileMultiFilterField = ({
|
|||
options: CampaignMobileMultiFilterOption[]
|
||||
selected: readonly string[]
|
||||
onToggle: (value: string) => void
|
||||
}) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<NativeSelect
|
||||
id={id}
|
||||
value=""
|
||||
onChange={(event) => {
|
||||
if (event.target.value) onToggle(event.target.value)
|
||||
}}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">
|
||||
{selected.length ? `${selected.length} selecionado(s) — alterar` : emptyLabel}
|
||||
</NativeSelectOption>
|
||||
{options.map((option) => (
|
||||
<NativeSelectOption key={option.value} value={option.value}>
|
||||
{selected.includes(option.value) ? `✓ ${option.label}` : option.label}
|
||||
}) => {
|
||||
// No snapshot: the NativeSelect closes on every choice, so the next open
|
||||
// already wants the just-toggled value at the top.
|
||||
const { ordered } = orderFilterOptionsSelectedFirst(options, selected)
|
||||
const selectedSet = new Set(selected)
|
||||
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<NativeSelect
|
||||
id={id}
|
||||
value=""
|
||||
onChange={(event) => {
|
||||
if (event.target.value) onToggle(event.target.value)
|
||||
}}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">
|
||||
{selected.length ? `${selected.length} selecionado(s) — alterar` : emptyLabel}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
)
|
||||
{ordered.map((option) => (
|
||||
<NativeSelectOption key={option.value} value={option.value}>
|
||||
{selectedSet.has(option.value) ? `✓ ${option.label}` : option.label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
33
src/lib/listFilterOptions.ts
Normal file
33
src/lib/listFilterOptions.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Presentation ordering for multi-select filter option lists: selected values
|
||||
* rise to the top in their original relative order; the rest keep theirs.
|
||||
* Pure / client-safe — consumed by the shared header Popover and the mobile
|
||||
* NativeSelect, never by a server facet loader.
|
||||
*/
|
||||
|
||||
type FilterOptionLike = { value: string }
|
||||
|
||||
export const orderFilterOptionsSelectedFirst = <T extends FilterOptionLike>(
|
||||
options: readonly T[],
|
||||
selected: readonly string[],
|
||||
): { ordered: readonly T[]; selectedCount: number } => {
|
||||
if (selected.length === 0) {
|
||||
return { ordered: options, selectedCount: 0 }
|
||||
}
|
||||
|
||||
const selectedSet = new Set(selected)
|
||||
const selectedOptions: T[] = []
|
||||
const rest: T[] = []
|
||||
|
||||
for (const option of options) {
|
||||
if (selectedSet.has(option.value)) selectedOptions.push(option)
|
||||
else rest.push(option)
|
||||
}
|
||||
|
||||
const selectedCount = selectedOptions.length
|
||||
if (selectedCount === 0 || selectedCount === options.length) {
|
||||
return { ordered: options, selectedCount }
|
||||
}
|
||||
|
||||
return { ordered: [...selectedOptions, ...rest], selectedCount }
|
||||
}
|
||||
201
tests/unit/campaignHeaderFilterPopover.unit.spec.ts
Normal file
201
tests/unit/campaignHeaderFilterPopover.unit.spec.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { createElement, useState } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('next/navigation', async (importActual) => ({
|
||||
...(await importActual()),
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
||||
}))
|
||||
|
||||
import {
|
||||
CampaignHeaderFilterPopover,
|
||||
type CampaignHeaderFilterRow,
|
||||
} from '@/components/campaign/shared/CampaignHeaderFilterPopover'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const labelsInOrder = (nav: HTMLElement) =>
|
||||
within(nav)
|
||||
.getAllByRole('link')
|
||||
.map((link) => link.textContent?.trim() ?? '')
|
||||
|
||||
const multiRows = (selected: ReadonlySet<string>): CampaignHeaderFilterRow[] =>
|
||||
[
|
||||
{ value: 'a', label: 'Alpha' },
|
||||
{ value: 'b', label: 'Beta' },
|
||||
{ value: 'c', label: 'Charlie' },
|
||||
{ value: 'd', label: 'Delta' },
|
||||
{ value: 'e', label: 'Echo' },
|
||||
{ value: 'f', label: 'Foxtrot' },
|
||||
{ value: 'g', label: 'Golf' },
|
||||
{ value: 'h', label: 'Hotel' },
|
||||
].map((option) => ({
|
||||
...option,
|
||||
href: `/campanha/municipios?region=${option.value}`,
|
||||
selected: selected.has(option.value),
|
||||
checkbox: true,
|
||||
onChoose: () => undefined,
|
||||
}))
|
||||
|
||||
const singleRows = (selected: string | null): CampaignHeaderFilterRow[] => [
|
||||
{
|
||||
value: '',
|
||||
label: 'Todas',
|
||||
href: '/campanha/municipios',
|
||||
selected: selected === null,
|
||||
onChoose: () => undefined,
|
||||
},
|
||||
{
|
||||
value: 'municipio',
|
||||
label: 'Município',
|
||||
href: '/campanha/municipios?kind=municipio',
|
||||
selected: selected === 'municipio',
|
||||
onChoose: () => undefined,
|
||||
},
|
||||
{
|
||||
value: 'zona',
|
||||
label: 'Zona eleitoral',
|
||||
href: '/campanha/municipios?kind=zona',
|
||||
selected: selected === 'zona',
|
||||
onChoose: () => undefined,
|
||||
},
|
||||
]
|
||||
|
||||
const openFilter = (label = 'Território') => {
|
||||
fireEvent.click(screen.getByRole('button', { name: new RegExp(`Filtrar.*${label}`) }))
|
||||
}
|
||||
|
||||
const optionNav = () => screen.getByRole('navigation', { name: 'Território' })
|
||||
|
||||
/** Popover + external button that replaces the selected set (simulates parent re-render). */
|
||||
const SnapshotHarness = ({
|
||||
initialSelected,
|
||||
nextSelected,
|
||||
buttonLabel,
|
||||
}: {
|
||||
initialSelected: ReadonlySet<string>
|
||||
nextSelected: ReadonlySet<string>
|
||||
buttonLabel: string
|
||||
}) => {
|
||||
const [selected, setSelected] = useState(() => new Set(initialSelected))
|
||||
return createElement(
|
||||
'div',
|
||||
null,
|
||||
createElement(CampaignHeaderFilterPopover, {
|
||||
id: 'filter-region',
|
||||
label: 'Território',
|
||||
active: true,
|
||||
optionRows: multiRows(selected),
|
||||
}),
|
||||
createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
onClick: () => setSelected(new Set(nextSelected)),
|
||||
},
|
||||
buttonLabel,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
describe('CampaignHeaderFilterPopover selected-first ordering', () => {
|
||||
it('lifts selected options to the top when the popover opens', () => {
|
||||
render(
|
||||
createElement(CampaignHeaderFilterPopover, {
|
||||
id: 'filter-region',
|
||||
label: 'Território',
|
||||
active: true,
|
||||
optionRows: multiRows(new Set(['c', 'a'])),
|
||||
}),
|
||||
)
|
||||
|
||||
openFilter()
|
||||
|
||||
expect(labelsInOrder(optionNav())).toEqual([
|
||||
'Alpha',
|
||||
'Charlie',
|
||||
'Beta',
|
||||
'Delta',
|
||||
'Echo',
|
||||
'Foxtrot',
|
||||
'Golf',
|
||||
'Hotel',
|
||||
])
|
||||
})
|
||||
|
||||
it('freezes order while open when the parent re-renders with a new selection', () => {
|
||||
render(
|
||||
createElement(SnapshotHarness, {
|
||||
initialSelected: new Set(['c', 'a']),
|
||||
nextSelected: new Set(['a']),
|
||||
buttonLabel: 'Drop Charlie',
|
||||
}),
|
||||
)
|
||||
openFilter()
|
||||
|
||||
expect(labelsInOrder(optionNav()).slice(0, 2)).toEqual(['Alpha', 'Charlie'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Drop Charlie' }))
|
||||
|
||||
// Snapshot still has both — Charlie stays at the top even though unchecked.
|
||||
expect(labelsInOrder(optionNav()).slice(0, 2)).toEqual(['Alpha', 'Charlie'])
|
||||
expect(screen.getByRole('link', { name: 'Charlie' }).getAttribute('aria-current')).toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'Alpha' }).getAttribute('aria-current')).toBe('true')
|
||||
})
|
||||
|
||||
it('regroups on the next open after closing', () => {
|
||||
render(
|
||||
createElement(SnapshotHarness, {
|
||||
initialSelected: new Set(['c', 'a']),
|
||||
nextSelected: new Set(['d']),
|
||||
buttonLabel: 'Select Delta only',
|
||||
}),
|
||||
)
|
||||
openFilter()
|
||||
// Close via the trigger (Radix toggles).
|
||||
openFilter()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select Delta only' }))
|
||||
openFilter()
|
||||
|
||||
expect(labelsInOrder(optionNav())[0]).toBe('Delta')
|
||||
})
|
||||
|
||||
it('keeps selected-first order among search matches', () => {
|
||||
render(
|
||||
createElement(CampaignHeaderFilterPopover, {
|
||||
id: 'filter-region',
|
||||
label: 'Território',
|
||||
active: true,
|
||||
optionRows: multiRows(new Set(['h', 'f'])),
|
||||
}),
|
||||
)
|
||||
|
||||
openFilter()
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Buscar em Território' }), {
|
||||
target: { value: 'o' },
|
||||
})
|
||||
|
||||
expect(labelsInOrder(optionNav())).toEqual(['Foxtrot', 'Hotel', 'Echo', 'Golf'])
|
||||
})
|
||||
|
||||
it('does not reorder single-select rows (no checkbox)', () => {
|
||||
render(
|
||||
createElement(CampaignHeaderFilterPopover, {
|
||||
id: 'filter-kind',
|
||||
label: 'Tipo',
|
||||
active: true,
|
||||
closeOnChoose: true,
|
||||
optionRows: singleRows('zona'),
|
||||
}),
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Filtrar.*Tipo/ }))
|
||||
|
||||
expect(labelsInOrder(screen.getByRole('navigation', { name: 'Tipo' }))).toEqual([
|
||||
'Todas',
|
||||
'Município',
|
||||
'Zona eleitoral',
|
||||
])
|
||||
})
|
||||
})
|
||||
53
tests/unit/listFilterOptions.unit.spec.ts
Normal file
53
tests/unit/listFilterOptions.unit.spec.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { orderFilterOptionsSelectedFirst } from '@/lib/listFilterOptions'
|
||||
|
||||
const options = [
|
||||
{ value: 'a', label: 'A' },
|
||||
{ value: 'b', label: 'B' },
|
||||
{ value: 'c', label: 'C' },
|
||||
{ value: 'd', label: 'D' },
|
||||
] as const
|
||||
|
||||
describe('orderFilterOptionsSelectedFirst', () => {
|
||||
it('returns the same reference when nothing is selected', () => {
|
||||
const result = orderFilterOptionsSelectedFirst(options, [])
|
||||
expect(result.ordered).toBe(options)
|
||||
expect(result.selectedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('returns the same reference when every option is selected', () => {
|
||||
const result = orderFilterOptionsSelectedFirst(options, ['a', 'b', 'c', 'd'])
|
||||
expect(result.ordered).toBe(options)
|
||||
expect(result.selectedCount).toBe(4)
|
||||
})
|
||||
|
||||
it('returns the same reference when selected values are all outside the set', () => {
|
||||
const result = orderFilterOptionsSelectedFirst(options, ['z', 'y'])
|
||||
expect(result.ordered).toBe(options)
|
||||
expect(result.selectedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('lifts selected options to the top in their original relative order', () => {
|
||||
const result = orderFilterOptionsSelectedFirst(options, ['c', 'a'])
|
||||
expect(result.ordered.map((option) => option.value)).toEqual(['a', 'c', 'b', 'd'])
|
||||
expect(result.selectedCount).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps unselected options in their original relative order', () => {
|
||||
const result = orderFilterOptionsSelectedFirst(options, ['d'])
|
||||
expect(result.ordered.map((option) => option.value)).toEqual(['d', 'a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('ignores selected values that are not among the options (stale facet)', () => {
|
||||
const result = orderFilterOptionsSelectedFirst(options, ['c', 'ghost'])
|
||||
expect(result.ordered.map((option) => option.value)).toEqual(['c', 'a', 'b', 'd'])
|
||||
expect(result.selectedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('does not duplicate when selected contains the same value twice', () => {
|
||||
const result = orderFilterOptionsSelectedFirst(options, ['b', 'b'])
|
||||
expect(result.ordered.map((option) => option.value)).toEqual(['b', 'a', 'c', 'd'])
|
||||
expect(result.selectedCount).toBe(1)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue