feat(C125): facet de ausência completo (qualquer_ausencia) e descoberta de ordenação no mobile
- PEOPLE_ABSENCES ganha o 4º valor qualquer_ausencia ("Qualquer ausência"),
a união exata das três ausências em UM chip — o 3º clique dos específicos
deixa de colapsar o facet (3 de 4 membros) e o parse absorve o guarda-chuva
sobre os específicos, sem tocar no B18 genérico de capacity/status
- peopleListSortPrimaryOptions: seeds do grupo Ordenação viram as 7 direções
primárias com emptyQueryVisible, então o mobile descobre as 7 chaves ao
tocar no bar (e "ordenar" não corta aliada/assessorado/base no cap 8)
- CampaignListOmnibox reabre o popover no mousedown: clicar num input já
focado (logo após commitar busca com Enter) não disparava focus, deixando
a descoberta morta no mobile até digitar
- tests: contrato de URL (absorção + não-colapso), predicado + AND, novo spec
de omnibox, e2e do 3º clique e da descoberta mobile (390×844)
This commit is contained in:
parent
b604157fc7
commit
7fabb72df8
9 changed files with 425 additions and 16 deletions
83
docs/plans/pessoas-pos-c117-ux-impl.md
Normal file
83
docs/plans/pessoas-pos-c117-ux-impl.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Impl: Pessoas pós-C117 — descoberta de ordenação no mobile e facet de ausência completo
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #681
|
||||
Intenção: docs/plans/pessoas-pos-c117-ux.md
|
||||
Appetite restante: herdado (~0,5–1 dia eng)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** (1) a consulta "quem está sem _algum_ dado" (fichas incompletas) é expressível no facet de ausência de `/campanha/pessoas` e o terceiro clique do facet não faz os chips sumirem; (2) no mobile (sem headers sortáveis) o usuário descobre que pode ordenar e alcança as 7 chaves de ordenação.
|
||||
- **O que NÃO negociar:** canonicalização B18 intacta (`parseExhaustiveEnumParam` — selecionar todos = "todas" = ausente); mesma superfície (omnibox de pessoas, sem rota nova); sem migration/collection; copy pt-BR / identificadores EN; achados S2/S3/S4/S6/S9/S10/S11/S13 do simplify continuam fora (gatilhos registrados na intenção).
|
||||
- **O que reavaliar:** a intenção deixa a forma do "qualquer ausência" em aberto (4º valor no enum OU toggle tri-state) e o desenho da descoberta mobile em aberto ("7 seeds de direção primária visíveis com query vazia, ou busca por coluna"). Decisões abaixo.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
S14[PEOPLE_ABSENCES + qual_ausencia] --> URL[parse/serialize canônico]
|
||||
URL --> DATA[peopleData: branch OR no filterPeopleRows]
|
||||
URL --> OMNI[peopleOmnibox: seeds Ausência + chips]
|
||||
S12[peopleListSortPrimaryOptions 7] --> OMNI2[peopleOmnibox: seeds Ordenação emptyQueryVisible]
|
||||
OMNI2 --> UI[CampaignListOmnibox: grupo visível no focus]
|
||||
```
|
||||
|
||||
**S14 — facet de ausência completo:** 4º valor `qualquer_ausencia` ("Qualquer ausência") no enum `PEOPLE_ABSENCES` (append-last). O colapso B18 é por contagem de membros (`parseExhaustiveEnumParam`), então com 4 membros:
|
||||
|
||||
- marcar os 3 específicos **não colapsa mais** (3 < 4) → os chips ficam e o filtro vale "alguma das três" — o bug do terceiro clique some sozinho, sem tocar no B18 genérico;
|
||||
- `ausencia=qualquer_ausencia` sozinho = o mesmo conjunto de resultados, em UM chip;
|
||||
- todos os 4 (URL digitada à mão) → colapso canônico B18, como sempre.
|
||||
Predicado novo em `filterPeopleRows`: `qualquer_ausencia` casa `!hasAdvisor || city === null || phone === null` (a união exata dos três), OR dentro do facet como os demais valores. **Absorção no parse (pós-review /simplify):** `parsePeopleListParams` para `ausencia` usa um parser próprio em vez de `parseExhaustiveEnumParam` — com 4 membros, o colapso genérico B18 ("todos os membros = ausente") dispararia no 4º clique (guarda-chuva + 3 específicos) — o bug dos chips sumirem um clique adiante. Regra: `qualquer_ausencia` presente ⇒ estado canônico `['qualquer_ausencia']` (o guarda-chuva absorve os específicos; o combo redundante vira 1 chip, e "todos os 4" deixa de colapsar). As 3 específicas seguem composicionais (cada chip removível); capacity/status continuam no parser genérico B18.
|
||||
|
||||
**Opções consideradas:** A (4º valor no enum, OR) | B (toggle tri-state no chip) | C (normalizar "3 específicos" → `qualquer_ausencia` no serializer)
|
||||
**Recomendação:** A — um valor no enum é a menor forma que mantém B18, é unit-testável no contrato de URL (parse/serialize) e no predicado, e o omnibox ganha o chip de graça via `peopleAbsenceFilterOptions`/labels derivados.
|
||||
**Rejeitadas:** B porque inventa estado de UI não-representável na URL (o facet vive todo na URL neste repo — precedente B18); C porque quebra a composicionalidade dos chips (3º clique trocaria 3 chips por 1) e é uma regra canônica nova específica de pessoas fora do B18 genérico — sem ganho de aceite. **Defer pós-review /simplify:** record de predicados por enum (`Record<PeopleAbsence, (row) => boolean>` com o guarda-chuva derivado) — o OR explícito em `filterPeopleRows` está ao lado das definições dos três termos (o código é o contrato); revisitar quando houver um 4º predicado de ausência (aí o guarda-chuva vira derivação real).
|
||||
|
||||
**S12 — descoberta de ordenação no mobile:** o cap de 8 por grupo (`campaignListOmnibox.ts`, compartilhado por 8+ listas) corta as 14 seeds (7 chaves × 2 direções) em 8, escondendo `aliada|assessorado|base`. Solução: as seeds do grupo "Ordenação" de pessoas passam a ser **as 7 direções primárias** (`dir === defaultPeopleListSortDir(key)`) — novo export `peopleListSortPrimaryOptions` em `peopleListUrl.ts` (derivação, não duplicação: `peopleListSortOptions` filtrado) — com `emptyQueryVisible: true`. Com query vazia o grupo aparece ao tocar no bar (padrão já usado por Capacidade/Apoio/Ausência — atalhos de dimensão); digitando "ordenar" → 7 hits, abaixo do cap, todas as chaves visíveis. `peopleListSortOptions` (14) continua sendo o catálogo canônico para chips/labels/apply/URL/headers.
|
||||
|
||||
**Opções consideradas:** A (7 seeds primárias + emptyQueryVisible) | B (manter 14, subir o cap do grupo no lib compartilhado) | C (keywords por coluna nas seeds)
|
||||
**Recomendação:** A — fixa o achado (7 chaves alcançáveis por keyword E visíveis no toque mobile), zero mudança no lib compartilhado (cap/ordem afetam todas as listas — municípios precisam do cap 8), e é exatamente o exemplo da intenção ("7 seeds de direção primária visíveis com query vazia").
|
||||
**Rejeitadas:** B porque o cap é compartilhado por design (grupo Município com centenas de opções) e um cap-por-grupo adiciona API ao lib para um caso; C porque não resolve o achado — digitando o genérico "ordenar" as 14 continuam batendo no cap 8 e as 3 chaves somem mesmo com keyword por coluna.
|
||||
|
||||
**Trade-off aceito (S12):** direções secundárias (ex. `name desc` Z–A, `lidera asc`) deixam de ser alcançáveis via omnibox; no desktop o header continua fazendo o flip (segundo clique), e no mobile a leitura secundária é a menos relevante (cards sem contexto direcional; a direção primária é a leitura pretendida — contagens `desc` "quem tem mais rede primeiro", textuais `asc`). Gatilho de revisitação: se a mesa pedir controle de direção no mobile, um padrão de seed por direção volta ao desenho.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`peopleListUrl.ts`**: `PEOPLE_ABSENCES` ganha `qualquer_ausencia` (último) + label "Qualquer ausência"; novo export `peopleListSortPrimaryOptions` (7, direção primária por chave). Parse/serialize/toggle/set ganham o 4º valor de graça (tudo derivado).
|
||||
- **`peopleData.ts`**: branch `qualquer_ausencia` no `filterPeopleRows` (união exata dos três predicados, OR dentro do facet).
|
||||
- **`peopleOmnibox.ts`**: seeds "Ausência" — keywords do grupo ganham `incompleto`/`incompleta` (linguagem natural de "ficha incompleta", achado S14); seeds "Ordenação" — loop passa a usar `peopleListSortPrimaryOptions` com `emptyQueryVisible: true`. Nada mais muda (chips/apply/remove são derivados dos mesmos options).
|
||||
- **Migration:** sem migration (param de URL + enum de código).
|
||||
- **Access / Consent:** nenhum — mesma superfície staff, nenhuma chave nova.
|
||||
- **UI:** Impeccable B — encaixe no omnibox existente; zero componente novo; `CampaignListOmnibox` já renderiza grupos com query vazia no focus.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- Nenhum dado novo: `qualquer_ausencia` é um valor de facet (predicado), não uma métrica; o group "Ordenação" com 7 itens no popover é a forma escolhida por caber no `max-h-72` scrollável do popover compartilhado e por ser o mesmo padrão visual dos atalhos de dimensão existentes (Capacidade/Apoio/Ausência já são `emptyQueryVisible`).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Puro (contrato + filtro + omnibox)** — `peopleListUrl` (4º valor + primary sort options), `filterPeopleRows` (qualquer_ausencia), `peopleOmnibox` (seeds/chips/apply); testes unitários novos/ajustados: `peopleListUrl.unit.spec.ts` (o teste de colapso "todas" passa a usar os 4 membros; 3 específicos não colapsam mais), `peopleMerge.unit.spec.ts` (união do qualquer_ausencia + AND com outros facets), **novo** `tests/unit/peopleOmnibox.unit.spec.ts` ("ordenar" → 7 seeds primárias; query vazia → grupo visível; apply/toggle/chips, incluindo chip de sort secundária aplicada via header com label completo).
|
||||
2. **E2E** — `campaignPeople.e2e.spec.ts`: (a) facet de ausência via omnibox com "Qualquer ausência" (chip único, pessoa sem contato visível, ficha completa oculta) e o fluxo "marcar os três" sem sumir chips; (b) mobile (`390×844`): foco no omnibox → grupo "Ordenação" visível → escolher "Lidera (maior → menor)" → URL `sort=lidera`.
|
||||
3. **Gates** — `pnpm gate:fast` (tsc, lint zero-warnings, format:check, knip, check:cycles, unit+int, e2e) + `pnpm build` local; commit e PR com auto-merge em main.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Cap-por-grupo no `campaignListOmnibox.ts` — o cap 8 é compartilhado de propósito; as 7 seeds primárias resolvem sem tocá-lo.
|
||||
- Normalizar "3 específicos" → `qualquer_ausencia` — ver rejeitadas (C).
|
||||
- Seeds de direção secundária no mobile — trade-off aceito, gatilho registrado.
|
||||
- S2/S3/S4/S6/S9/S10/S11/S13 do simplify — fora, gatilhos na intenção.
|
||||
- Outras listas (municípios/lideranças têm o mesmo gap mobile de descoberta de sort) — escopo é pessoas (a intenção é do recorte de pessoas; abrir para outras listas é item novo).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **B18 fica intacto?** O colapso agora exige os 4 membros; testes de `parseExhaustiveEnumParam` e o teste atualizado de pessoas garantem que selecionar todos = ausente continua sendo a regra genérica (o teste do contrato muda de 3 para 4 membros).
|
||||
- **Cap corta as seeds primárias?** 7 < 8 — e o teste novo de omnibox fixa "todas as 7 chaves aparecem com 'ordenar'".
|
||||
- **Chip de sort secundária (aplicada no desktop) perde o label?** `buildPeopleOmniboxChips` continua usando `peopleListSortOptions` completo — coberto por teste unitário novo.
|
||||
- **e2e mobile flaky (stream dinâmico)?** reuso do padrão `campaignUpdatesMobile.e2e.spec.ts` (`MOBILE_VIEWPORT` + locators visíveis); asserções por URL/chip, não por ordem visual onde possível.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (fichas incompletas expressível; 7 chaves alcançáveis e visíveis no mobile; B18 intacto)
|
||||
- [x] Invariantes AGENTS/engineering-standards (sem migration/collection, sem access novo, identificadores EN / copy pt-BR)
|
||||
- [x] Testes de domínio previstos (unit do contrato + filtro + omnibox; e2e do facet e da descoberta mobile)
|
||||
|
|
@ -217,7 +217,10 @@ export const CampaignListOmnibox = ({
|
|||
'md:focus-within:border-ring md:focus-within:ring-3 md:focus-within:ring-ring/50',
|
||||
)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) inputRef.current?.focus()
|
||||
if (event.target === event.currentTarget) {
|
||||
inputRef.current?.focus()
|
||||
setOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{chips.map((chip) => (
|
||||
|
|
@ -267,6 +270,11 @@ export const CampaignListOmnibox = ({
|
|||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
// C125 — a click on an already-focused input (e.g. right after
|
||||
// committing a search with Enter) fires no focus event, which
|
||||
// left the popover closed on mobile until a keystroke: the
|
||||
// mousedown always fires, so this is the reliable reopen path.
|
||||
onMouseDown={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
className="min-h-8 min-w-[8rem] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -353,10 +353,16 @@ export const filterPeopleRows = (
|
|||
if (state.ausencias?.length) {
|
||||
const wanted = new Set(state.ausencias)
|
||||
const hasAdvisor = row.leadershipAdvisorIDs.length > 0 || row.deputyAdvisorIDs.length > 0
|
||||
const semAssessor = !hasAdvisor
|
||||
const semBase = row.city === null
|
||||
const semContato = row.phone === null
|
||||
const matches =
|
||||
(wanted.has('sem_assessor') && !hasAdvisor) ||
|
||||
(wanted.has('sem_base') && row.city === null) ||
|
||||
(wanted.has('sem_contato') && row.phone === null)
|
||||
(wanted.has('sem_assessor') && semAssessor) ||
|
||||
(wanted.has('sem_base') && semBase) ||
|
||||
(wanted.has('sem_contato') && semContato) ||
|
||||
// C125 — "Qualquer ausência": the exact union of the three predicates,
|
||||
// one chip for "fichas incompletas" (same OR-within-facet semantics).
|
||||
(wanted.has('qualquer_ausencia') && (semAssessor || semBase || semContato))
|
||||
if (!matches) return false
|
||||
}
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -43,15 +43,24 @@ export type PeopleListSortDirection = 'asc' | 'desc'
|
|||
* C117 — absence facets ("Sem assessor", "Sem base", "Sem contato"). Each
|
||||
* value is a pure absence predicate over the merged row; the facet is OR
|
||||
* within itself (same semantics as every multi-select facet) and AND with the
|
||||
* other filters.
|
||||
* other filters. C125 — `qualquer_ausencia` is the umbrella value: the union
|
||||
* of the three predicates, so "fichas incompletas" is a single chip instead of
|
||||
* the three-specific combination collapsing under B18 (with 4 members,
|
||||
* selecting the 3 specifics no longer equals "all members").
|
||||
*/
|
||||
export const PEOPLE_ABSENCES = ['sem_assessor', 'sem_base', 'sem_contato'] as const
|
||||
export const PEOPLE_ABSENCES = [
|
||||
'sem_assessor',
|
||||
'sem_base',
|
||||
'sem_contato',
|
||||
'qualquer_ausencia',
|
||||
] as const
|
||||
export type PeopleAbsence = (typeof PEOPLE_ABSENCES)[number]
|
||||
|
||||
export const peopleAbsenceLabels: Record<PeopleAbsence, string> = {
|
||||
sem_assessor: 'Sem assessor',
|
||||
sem_base: 'Sem base',
|
||||
sem_contato: 'Sem contato',
|
||||
qualquer_ausencia: 'Qualquer ausência',
|
||||
}
|
||||
|
||||
export type PeopleListState = {
|
||||
|
|
@ -101,6 +110,23 @@ const peopleListSortKeySet = new Set<string>(Object.keys(peopleListSortLabels))
|
|||
const peopleListSortDirSet = new Set<PeopleListSortDirection>(['asc', 'desc'])
|
||||
const peopleAbsenceSet = new Set<string>(PEOPLE_ABSENCES)
|
||||
|
||||
/**
|
||||
* C125 — ausência parse with the umbrella absorbing the specifics. This facet
|
||||
* deliberately does NOT use `parseExhaustiveEnumParam`: once
|
||||
* `qualquer_ausencia` exists, selecting every member is NOT the same filter as
|
||||
* selecting none (the umbrella means "any absence"), so the generic B18
|
||||
* collapse would fire on the 4th click — the exact "chips vanish" bug this
|
||||
* issue fixes, one click further. The umbrella subsumes the three specifics
|
||||
* (one canonical "any absence" chip) while the 3-specific combination stays
|
||||
* compositional (each chip individually removable).
|
||||
*/
|
||||
const parsePeopleAbsenceParam = (raw: string | string[] | undefined): PeopleAbsence[] => {
|
||||
const values = allParamValues(raw).filter((token): token is PeopleAbsence =>
|
||||
peopleAbsenceSet.has(token),
|
||||
)
|
||||
return values.includes('qualquer_ausencia') ? ['qualquer_ausencia'] : values
|
||||
}
|
||||
|
||||
/** Categorical/textual keys open A–Z; count keys open on the biggest first. */
|
||||
const peopleSortKeysWithDescDefault = new Set<PeopleListSortKey>([
|
||||
'assessora',
|
||||
|
|
@ -132,7 +158,7 @@ export const parsePeopleListParams = (params: PeopleListSearchParams): PeopleLis
|
|||
.map((token) => strictDecimalInteger(token))
|
||||
.filter((id): id is number => typeof id === 'number' && id > 0)
|
||||
const statuses = parseExhaustiveEnumParam<SupportStatus>(params.status, supportStatusSet)
|
||||
const ausencias = parseExhaustiveEnumParam<PeopleAbsence>(params.ausencia, peopleAbsenceSet)
|
||||
const ausencias = parsePeopleAbsenceParam(params.ausencia)
|
||||
const rawSort = firstValue(params.sort)
|
||||
const sort =
|
||||
rawSort && peopleListSortKeySet.has(rawSort) ? (rawSort as PeopleListSortKey) : undefined
|
||||
|
|
@ -217,6 +243,17 @@ export const peopleListSortOptions = (
|
|||
(['asc', 'desc'] as const).map((dir) => ({ key, dir, label: peopleSortOptionLabel(key, dir) })),
|
||||
)
|
||||
|
||||
/**
|
||||
* C125 — the omnibox sort catalog: ONE option per key, in the key's default
|
||||
* direction. Mobile has no sortable headers, and the shared omnibox cap (8 per
|
||||
* group) cuts the 14-key×dir catalog before `aliada`/`assessorado`/`base`;
|
||||
* the primary direction is the intended reading (rankings biggest first, text
|
||||
* A–Z), and desktop headers keep flipping to the secondary one.
|
||||
*/
|
||||
export const peopleListSortPrimaryOptions = peopleListSortOptions.filter(
|
||||
({ key, dir }) => dir === defaultPeopleListSortDir(key),
|
||||
)
|
||||
|
||||
export const resolvePeopleListUrl = (
|
||||
params: PeopleListSearchParams,
|
||||
totalPages?: number,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
isDefaultPeopleListSort,
|
||||
peopleAbsenceLabels,
|
||||
peopleListSortOptions,
|
||||
peopleListSortPrimaryOptions,
|
||||
resolvePeopleListSort,
|
||||
type PeopleListState,
|
||||
} from '@/utilities/people/peopleListUrl'
|
||||
|
|
@ -138,21 +139,35 @@ export const buildPeopleOmniboxSuggestionSeeds = ({
|
|||
id: `ausencia:${option.value}`,
|
||||
group: 'Ausência',
|
||||
label: option.label,
|
||||
keywords: ['sem', 'ausencia', 'faltando', 'faltante', 'vazio'],
|
||||
keywords: [
|
||||
'sem',
|
||||
'ausencia',
|
||||
'faltando',
|
||||
'faltante',
|
||||
'vazio',
|
||||
'incompleto',
|
||||
'incompleta',
|
||||
],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const option of peopleListSortOptions) {
|
||||
// C125 — primary-direction seeds only, visible on an empty query: on mobile
|
||||
// the sortable headers do not exist (cards), so the omnibox group is the
|
||||
// whole discoverability surface for the 7 keys (and 7 < the shared 8/group cap).
|
||||
for (const option of peopleListSortPrimaryOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed({
|
||||
id: `sort:${option.key}|${option.dir}`,
|
||||
group: 'Ordenação',
|
||||
label: option.label,
|
||||
keywords: ['ordenar', 'ordenacao', 'ordem', 'sort'],
|
||||
}),
|
||||
createOmniboxSuggestionSeed(
|
||||
{
|
||||
id: `sort:${option.key}|${option.dir}`,
|
||||
group: 'Ordenação',
|
||||
label: option.label,
|
||||
keywords: ['ordenar', 'ordenacao', 'ordem', 'sort'],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -268,5 +268,80 @@ test.describe('Pessoas — lista unificada', () => {
|
|||
await expect(page.getByText('Ausência: Sem contato')).toBeVisible()
|
||||
await expect(page.getByRole('row', { name: new RegExp(noPhoneName) })).toBeVisible()
|
||||
await expect(page.getByRole('row', { name: new RegExp(twoCities.contactName) })).toBeHidden()
|
||||
|
||||
// C125 — the third absence click no longer collapses the facet: with the
|
||||
// umbrella value in the enum, "all three" is 3 of 4 members, so B18 keeps
|
||||
// the chips and the union ("any absence") stays applied.
|
||||
await omnibox.fill('sem base')
|
||||
await page.getByRole('option', { name: 'Sem base', exact: true }).click()
|
||||
await expect(page).toHaveURL(
|
||||
/q=C117&ausencia=sem_contato&ausencia=sem_base&sort=lidera&dir=asc$/,
|
||||
)
|
||||
await expect(page.getByText('Ausência: Sem base')).toBeVisible()
|
||||
|
||||
await omnibox.fill('sem assessor')
|
||||
await page.getByRole('option', { name: 'Sem assessor', exact: true }).click()
|
||||
await expect(page).toHaveURL(
|
||||
/q=C117&ausencia=sem_contato&ausencia=sem_base&ausencia=sem_assessor&sort=lidera&dir=asc$/,
|
||||
)
|
||||
await expect(page.getByText('Ausência: Sem contato')).toBeVisible()
|
||||
await expect(page.getByText('Ausência: Sem base')).toBeVisible()
|
||||
await expect(page.getByText('Ausência: Sem assessor')).toBeVisible()
|
||||
await expect(page.getByRole('row', { name: new RegExp(noPhoneName) })).toBeVisible()
|
||||
// The union widened: the staff leaderships have no advisors, so they match
|
||||
// `sem_assessor` — the row hidden by `sem_contato` alone is back in.
|
||||
await expect(page.getByRole('row', { name: new RegExp(twoCities.contactName) })).toBeVisible()
|
||||
})
|
||||
|
||||
test('mobile discovers ordering in the omnibox and applies a sort (C125)', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Mobile C125'),
|
||||
})
|
||||
const first = await fixtures.claimMunicipality()
|
||||
const second = await fixtures.claimMunicipality()
|
||||
const oneCity = await fixtures.createStaffLeadership({
|
||||
namePrefix: 'C125 Mobile Uma',
|
||||
municipalities: [first],
|
||||
})
|
||||
const twoCities = await fixtures.createStaffLeadership({
|
||||
namePrefix: 'C125 Mobile Duas',
|
||||
municipalities: [first, second],
|
||||
})
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto('/campanha/pessoas')
|
||||
|
||||
// Narrow the recorte to this spec's rows, so the card order is ours.
|
||||
const omnibox = page.getByRole('combobox', { name: 'Filtrar pessoas' })
|
||||
await omnibox.fill('C125 Mobile')
|
||||
await omnibox.press('Enter')
|
||||
await expect(page).toHaveURL(/q=C125/)
|
||||
|
||||
// The Ordenação group is discoverable on focus with an empty query —
|
||||
// mobile cards have no sortable headers, the omnibox is the whole surface.
|
||||
await omnibox.click()
|
||||
await expect(page.getByRole('group', { name: 'Ordenação' })).toBeVisible()
|
||||
await page.getByRole('option', { name: 'Lidera (maior → menor)', exact: true }).click()
|
||||
await expect(page).toHaveURL(/q=C125.*&sort=lidera$/)
|
||||
await expect(page.getByText('Ordenação: Lidera (maior → menor)')).toBeVisible()
|
||||
|
||||
// The mobile cards follow the sort: most municipalities first.
|
||||
const cards = page.locator('[data-view="mobile-cards"] li')
|
||||
const indexOfCard = async (name: string) => {
|
||||
const texts = await cards.allTextContents()
|
||||
return texts.findIndex((text) => text.includes(name))
|
||||
}
|
||||
await expect(cards.filter({ hasText: twoCities.contactName })).toBeVisible()
|
||||
await expect(cards.filter({ hasText: oneCity.contactName })).toBeVisible()
|
||||
const twoCitiesIndex = await indexOfCard(twoCities.contactName)
|
||||
const oneCityIndex = await indexOfCard(oneCity.contactName)
|
||||
expect(twoCitiesIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(oneCityIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(twoCitiesIndex).toBeLessThan(oneCityIndex)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import {
|
|||
buildPeopleListHref,
|
||||
buildPeopleSortHref,
|
||||
buildPeopleStaffSourceWhere,
|
||||
defaultPeopleListSortDir,
|
||||
parsePeopleListParams,
|
||||
peopleListSortPrimaryOptions,
|
||||
resolvePeopleListUrl,
|
||||
serializeCanonicalPeopleListSearchParams,
|
||||
} from '@/utilities/people/peopleListUrl'
|
||||
|
|
@ -70,9 +72,27 @@ describe('people list URL contract', () => {
|
|||
parsePeopleListParams({ ausencia: ['sem_base', 'sem_assessor', 'sem_base', 'nada'] }),
|
||||
).toEqual({ page: 1, ausencias: ['sem_base', 'sem_assessor'] })
|
||||
expect(parsePeopleListParams({ ausencia: 'sem_e_mail' })).toEqual({ page: 1 })
|
||||
expect(parsePeopleListParams({ ausencia: 'qualquer_ausencia' })).toEqual({
|
||||
page: 1,
|
||||
ausencias: ['qualquer_ausencia'],
|
||||
})
|
||||
// C125 — the three specifics are 3 of 4 members, so they no longer
|
||||
// collapse: the "mark all three" interaction keeps its chips and means
|
||||
// "any absence" (the same result set as `qualquer_ausencia`).
|
||||
expect(
|
||||
parsePeopleListParams({ ausencia: ['sem_assessor', 'sem_base', 'sem_contato'] }),
|
||||
).toEqual({ page: 1 })
|
||||
).toEqual({ page: 1, ausencias: ['sem_assessor', 'sem_base', 'sem_contato'] })
|
||||
// C125 — the umbrella absorbs the specifics (one canonical "any absence"
|
||||
// chip), so neither the redundant combo nor selecting all four members can
|
||||
// fire B18's generic collapse and wipe the facet mid-interaction.
|
||||
expect(
|
||||
parsePeopleListParams({ ausencia: ['qualquer_ausencia', 'sem_contato', 'sem_base'] }),
|
||||
).toEqual({ page: 1, ausencias: ['qualquer_ausencia'] })
|
||||
expect(
|
||||
parsePeopleListParams({
|
||||
ausencia: ['sem_assessor', 'sem_base', 'sem_contato', 'qualquer_ausencia'],
|
||||
}),
|
||||
).toEqual({ page: 1, ausencias: ['qualquer_ausencia'] })
|
||||
})
|
||||
|
||||
it('canonicalizes selecting every status member to the absent filter (C119)', () => {
|
||||
|
|
@ -104,6 +124,13 @@ describe('people list URL contract', () => {
|
|||
}).toString(),
|
||||
).toBe('ausencia=sem_base&ausencia=sem_contato')
|
||||
|
||||
expect(
|
||||
serializeCanonicalPeopleListSearchParams({
|
||||
page: 1,
|
||||
ausencias: ['qualquer_ausencia'],
|
||||
}).toString(),
|
||||
).toBe('ausencia=qualquer_ausencia')
|
||||
|
||||
expect(
|
||||
serializeCanonicalPeopleListSearchParams({ page: 1, sort: 'name', dir: 'asc' }).toString(),
|
||||
).toBe('')
|
||||
|
|
@ -168,6 +195,22 @@ describe('people list URL contract', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('derives the omnibox primary sort options: one per key, in its default direction', () => {
|
||||
expect(peopleListSortPrimaryOptions).toHaveLength(7)
|
||||
expect(peopleListSortPrimaryOptions.map((option) => option.key)).toEqual([
|
||||
'name',
|
||||
'contact',
|
||||
'assessora',
|
||||
'lidera',
|
||||
'aliada',
|
||||
'assessorado',
|
||||
'base',
|
||||
])
|
||||
for (const option of peopleListSortPrimaryOptions) {
|
||||
expect(option.dir).toBe(defaultPeopleListSortDir(option.key))
|
||||
}
|
||||
})
|
||||
|
||||
it('clamps an out-of-range page to totalPages', () => {
|
||||
const resolved = resolvePeopleListUrl({ page: '9' }, 3)
|
||||
expect(resolved.state.page).toBe(3)
|
||||
|
|
|
|||
|
|
@ -237,6 +237,39 @@ describe('filterPeopleRows', () => {
|
|||
).toEqual([101, 102, 103])
|
||||
})
|
||||
|
||||
it('filters "qualquer ausência" as the exact union of the three predicates (C125)', () => {
|
||||
expect(
|
||||
filterPeopleRows(rows, { page: 1, ausencias: ['qualquer_ausencia'] })
|
||||
.map((row) => row.contactID)
|
||||
.sort(),
|
||||
).toEqual([101, 102, 103])
|
||||
expect(
|
||||
filterPeopleRows(rows, {
|
||||
page: 1,
|
||||
ausencias: ['qualquer_ausencia', 'sem_contato'],
|
||||
})
|
||||
.map((row) => row.contactID)
|
||||
.sort(),
|
||||
).toEqual([101, 102, 103])
|
||||
})
|
||||
|
||||
it('combines "qualquer ausência" with the other filters (AND across facets)', () => {
|
||||
expect(
|
||||
filterPeopleRows(rows, {
|
||||
page: 1,
|
||||
municipalities: [5],
|
||||
ausencias: ['qualquer_ausencia'],
|
||||
}).map((row) => row.contactID),
|
||||
).toEqual([101])
|
||||
expect(
|
||||
filterPeopleRows(rows, {
|
||||
page: 1,
|
||||
statuses: ['engajado'],
|
||||
ausencias: ['qualquer_ausencia'],
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('combines absence facets with the other filters (AND across facets)', () => {
|
||||
expect(
|
||||
filterPeopleRows(rows, { page: 1, capacities: ['assessora'], ausencias: ['sem_base'] }).map(
|
||||
|
|
|
|||
109
tests/unit/peopleOmnibox.unit.spec.ts
Normal file
109
tests/unit/peopleOmnibox.unit.spec.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parsePeopleListParams } from '@/utilities/people/peopleListUrl'
|
||||
import {
|
||||
applyPeopleOmniboxSuggestion,
|
||||
buildPeopleOmniboxChips,
|
||||
buildPeopleOmniboxSuggestionSeeds,
|
||||
filterPeopleOmniboxSuggestions,
|
||||
removePeopleOmniboxChip,
|
||||
} from '@/utilities/people/peopleOmnibox'
|
||||
|
||||
const seeds = buildPeopleOmniboxSuggestionSeeds({ municipalityFilterOptions: [] })
|
||||
|
||||
const sortSeedsIn = (query: string) =>
|
||||
filterPeopleOmniboxSuggestions(seeds, query).filter((entry) => entry.group === 'Ordenação')
|
||||
|
||||
describe('people omnibox — sort discovery (C125)', () => {
|
||||
it('typing "ordenar" surfaces every sort key, one seed each in the primary direction', () => {
|
||||
const suggestions = sortSeedsIn('ordenar')
|
||||
expect(suggestions.map((entry) => entry.id)).toEqual([
|
||||
'sort:name|asc',
|
||||
'sort:contact|asc',
|
||||
'sort:assessora|desc',
|
||||
'sort:lidera|desc',
|
||||
'sort:aliada|desc',
|
||||
'sort:assessorado|desc',
|
||||
'sort:base|asc',
|
||||
])
|
||||
expect(suggestions.map((entry) => entry.label)).toContain('Aliada em (maior → menor)')
|
||||
expect(suggestions.map((entry) => entry.label)).toContain('Base (A–Z)')
|
||||
})
|
||||
|
||||
it('shows the Ordenação group on an empty query (mobile discovery)', () => {
|
||||
const suggestions = filterPeopleOmniboxSuggestions(seeds, '')
|
||||
expect(suggestions.some((entry) => entry.group === 'Ordenação')).toBe(true)
|
||||
expect(sortSeedsIn('')).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('matches a sort column by its label, e.g. "aliada"', () => {
|
||||
const suggestions = sortSeedsIn('aliada')
|
||||
expect(suggestions.map((entry) => entry.id)).toEqual(['sort:aliada|desc'])
|
||||
})
|
||||
|
||||
it('applies a sort suggestion and resets to page 1', () => {
|
||||
const applied = applyPeopleOmniboxSuggestion({
|
||||
state: parsePeopleListParams({ page: '3', q: 'ana' }),
|
||||
suggestionId: 'sort:aliada|desc',
|
||||
})
|
||||
expect(applied.kind).toBe('url')
|
||||
if (applied.kind === 'url') {
|
||||
expect(applied.state).toEqual({ page: 1, q: 'ana', sort: 'aliada', dir: 'desc' })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the full option catalog for the chip label of a header-applied secondary direction', () => {
|
||||
const chips = buildPeopleOmniboxChips({
|
||||
state: parsePeopleListParams({ sort: 'name', dir: 'desc' }),
|
||||
municipalityLabelsById: new Map(),
|
||||
})
|
||||
expect(chips.some((chip) => chip.id === 'sort' && chip.label === 'Ordenação: Nome (Z–A)')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('people omnibox — "Qualquer ausência" facet (C125)', () => {
|
||||
it('suggests the umbrella absence with the natural "incompleto" keyword', () => {
|
||||
const byKeyword = filterPeopleOmniboxSuggestions(seeds, 'incompleto').filter(
|
||||
(entry) => entry.group === 'Ausência',
|
||||
)
|
||||
expect(byKeyword.some((entry) => entry.id === 'ausencia:qualquer_ausencia')).toBe(true)
|
||||
expect(byKeyword.map((entry) => entry.id).sort()).toEqual([
|
||||
'ausencia:qualquer_ausencia',
|
||||
'ausencia:sem_assessor',
|
||||
'ausencia:sem_base',
|
||||
'ausencia:sem_contato',
|
||||
])
|
||||
|
||||
const byLabel = filterPeopleOmniboxSuggestions(seeds, 'qualquer')
|
||||
expect(byLabel.some((entry) => entry.id === 'ausencia:qualquer_ausencia')).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles the umbrella value like any other facet value', () => {
|
||||
const added = applyPeopleOmniboxSuggestion({
|
||||
state: parsePeopleListParams({}),
|
||||
suggestionId: 'ausencia:qualquer_ausencia',
|
||||
})
|
||||
expect(added.kind).toBe('url')
|
||||
if (added.kind === 'url') {
|
||||
expect(added.state.ausencias).toEqual(['qualquer_ausencia'])
|
||||
}
|
||||
|
||||
const removed = removePeopleOmniboxChip({
|
||||
state: parsePeopleListParams({ ausencia: 'qualquer_ausencia' }),
|
||||
chipId: 'ausencia:qualquer_ausencia',
|
||||
})
|
||||
expect(removed.kind).toBe('url')
|
||||
if (removed.kind === 'url') expect(removed.state.ausencias).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders the umbrella chip with its pt-BR label', () => {
|
||||
const chips = buildPeopleOmniboxChips({
|
||||
state: parsePeopleListParams({ ausencia: 'qualquer_ausencia' }),
|
||||
municipalityLabelsById: new Map(),
|
||||
})
|
||||
expect(chips.map((chip) => chip.id)).toEqual(['ausencia:qualquer_ausencia'])
|
||||
expect(chips[0]?.label).toBe('Ausência: Qualquer ausência')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue