diff --git a/docs/plans/pessoas-pos-c117-ux-impl.md b/docs/plans/pessoas-pos-c117-ux-impl.md new file mode 100644 index 00000000..fa5bdbf9 --- /dev/null +++ b/docs/plans/pessoas-pos-c117-ux-impl.md @@ -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 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) diff --git a/src/components/campaign/shared/CampaignListOmnibox.tsx b/src/components/campaign/shared/CampaignListOmnibox.tsx index c20521be..6ae89c3a 100644 --- a/src/components/campaign/shared/CampaignListOmnibox.tsx +++ b/src/components/campaign/shared/CampaignListOmnibox.tsx @@ -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" /> diff --git a/src/utilities/people/peopleData.ts b/src/utilities/people/peopleData.ts index 8134fbd4..d448fc04 100644 --- a/src/utilities/people/peopleData.ts +++ b/src/utilities/people/peopleData.ts @@ -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 diff --git a/src/utilities/people/peopleListUrl.ts b/src/utilities/people/peopleListUrl.ts index 1f506cb3..9885b517 100644 --- a/src/utilities/people/peopleListUrl.ts +++ b/src/utilities/people/peopleListUrl.ts @@ -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 = { 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(Object.keys(peopleListSortLabels)) const peopleListSortDirSet = new Set(['asc', 'desc']) const peopleAbsenceSet = new Set(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([ '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(params.status, supportStatusSet) - const ausencias = parseExhaustiveEnumParam(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, diff --git a/src/utilities/people/peopleOmnibox.ts b/src/utilities/people/peopleOmnibox.ts index d1e95d77..f3d0edf5 100644 --- a/src/utilities/people/peopleOmnibox.ts +++ b/src/utilities/people/peopleOmnibox.ts @@ -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 }, + ), ) } diff --git a/tests/e2e/campaignPeople.e2e.spec.ts b/tests/e2e/campaignPeople.e2e.spec.ts index 53c76ec9..753311b7 100644 --- a/tests/e2e/campaignPeople.e2e.spec.ts +++ b/tests/e2e/campaignPeople.e2e.spec.ts @@ -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) }) }) diff --git a/tests/unit/peopleListUrl.unit.spec.ts b/tests/unit/peopleListUrl.unit.spec.ts index a8e9dea8..57ac85ff 100644 --- a/tests/unit/peopleListUrl.unit.spec.ts +++ b/tests/unit/peopleListUrl.unit.spec.ts @@ -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) diff --git a/tests/unit/peopleMerge.unit.spec.ts b/tests/unit/peopleMerge.unit.spec.ts index 2e8acf64..e62fed6e 100644 --- a/tests/unit/peopleMerge.unit.spec.ts +++ b/tests/unit/peopleMerge.unit.spec.ts @@ -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( diff --git a/tests/unit/peopleOmnibox.unit.spec.ts b/tests/unit/peopleOmnibox.unit.spec.ts new file mode 100644 index 00000000..60f0e5c7 --- /dev/null +++ b/tests/unit/peopleOmnibox.unit.spec.ts @@ -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') + }) +})