Merge branch 'cursor/a907248c'
This commit is contained in:
commit
eab4dae430
31 changed files with 1983 additions and 1201 deletions
|
|
@ -233,4 +233,5 @@ alwaysApply: false
|
|||
- **B7 entregue (2026-07-21):** mapa em `/campanha/municipios` usa `buildMunicipalityListWhere` (lista/overview/mapa alinhados). Plano: [`docs/plans/mapa-pracas-filtrado.md`](../../../docs/plans/mapa-pracas-filtrado.md). Débito perf triplicado → **A9+**.
|
||||
- **A9 / B6 / B9 / B7 / B10 / B11 / B8 F1 / filtros-auto entregues (2026-07-21; em produção desde 2026-07-23):** ver bullets no topo do Status. Próximos abertos: ver [`docs/roadmap.md`](../../../docs/roadmap.md) (compile 2026-07-24) — Janela 1: smoke pós-deploy + **R6** (+ **E8** em paralelo); Janela 3: **B8 F2** (polígonos das zonas de Salvador).
|
||||
- **B8 registrado (2026-07-21); F1 entregue (2026-07-21); F2 entregue (2026-07-26):** polígonos das Praças-zona Salvador/Camaçari (Camaçari saiu do escopo na M1). F1: catálogo `municipalityZoneNeighborhoods` + card na Praça. F2: dissolve malha IBGE → TopoJSON no mapa. Plano: [`docs/plans/poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md).
|
||||
- **B34 entregue (2026-07-27):** a coluna "Municípios" de `/campanha/liderancas` virou chips editáveis — link para o município, chip de território com contagem quando a liderança cobre o TI inteiro, "×" no canto do chip (hover/foco, sem mexer no tamanho) e busca no fim da linha. **Decisão de produto que vale para toda a vertical:** o que decide inline vs. Drawer é a mídia `pointer:`, não a largura do viewport — `pointer-fine` recebe o editor sempre montado, `pointer-coarse` abre um Drawer com alvos de 44 px, porque um polegar rolando a tabela não pode ser convidado a distinguir "abrir" de "apagar". O anti-goal "input sempre montado em toda linha" (`PRODUCT.md` princípio 3 + `campanha-edit-where-you-see.mdc`) foi **emendado** para relações por chips em ponteiro fino, e não reabre B28/B31/B32: nada aqui é modo de tabela (o toggle de `AdvisorsTable` virou "Editar nome e contato"). Engenharia: `AdvisorMunicipalityCell` foi promovido a `shared/MunicipalityPortfolioCell` (o `ResizeObserver`/"Ver mais…" que o plano chamava de peso morto virou requisito quando a célula passou a estar sempre visível) e Assessores migrou no mesmo PR; uma única action de lote sobrevive (a gêmea de assessor foi deletada, int tests migrados); `LeadershipRowViewModel.municipalityNames` → `municipalityIDs`. Corrigiu de passagem um bug latente do seam `description` de `CampaignTableHead` (**B22**): os helpers do tooltip moravam num módulo `'use client'`, então qualquer chamador RSC quebrava. Débitos: `LeadershipStateDeputyRelationCell` (B31) segue com Popover próprio e é ele — não o B37 — o gatilho de extração de um editor de chips agnóstico. Plano: [`docs/plans/chips-municipios-lista-liderancas.md`](../../../docs/plans/chips-municipios-lista-liderancas.md).
|
||||
- **B34 entregue (2026-07-27):** a coluna "Municípios" de `/campanha/liderancas` virou chips editáveis — link para o município, chip de território com contagem quando a liderança cobre o TI inteiro, "×" no canto do chip (hover/foco, sem mexer no tamanho) e busca no fim da linha. **Decisão de produto que vale para toda a vertical:** o que decide inline vs. Drawer é a mídia `pointer:`, não a largura do viewport — `pointer-fine` recebe o editor sempre montado, `pointer-coarse` abre um Drawer com alvos de 44 px, porque um polegar rolando a tabela não pode ser convidado a distinguir "abrir" de "apagar". O anti-goal "input sempre montado em toda linha" (`PRODUCT.md` princípio 3 + `campanha-edit-where-you-see.mdc`) foi **emendado** para relações por chips em ponteiro fino, e não reabre B28/B31/B32: nada aqui é modo de tabela (o toggle de `AdvisorsTable` virou "Editar nome e contato"). Engenharia: `AdvisorMunicipalityCell` foi promovido a `shared/MunicipalityPortfolioCell` (o `ResizeObserver`/"Ver mais…" que o plano chamava de peso morto virou requisito quando a célula passou a estar sempre visível) e Assessores migrou no mesmo PR; uma única action de lote sobrevive (a gêmea de assessor foi deletada, int tests migrados); `LeadershipRowViewModel.municipalityNames` → `municipalityIDs`. Corrigiu de passagem um bug latente do seam `description` de `CampaignTableHead` (**B22**): os helpers do tooltip moravam num módulo `'use client'`, então qualquer chamador RSC quebrava. Débitos: `LeadershipStateDeputyRelationCell` (B31) seguiu com Popover próprio até o **B37 ✓** generalizar os dois num motor comum. Plano: [`docs/plans/chips-municipios-lista-liderancas.md`](../../../docs/plans/chips-municipios-lista-liderancas.md).
|
||||
- **B37 entregue (2026-07-28):** a coluna "Municípios" de `/campanha/dobradinhas` ganhou a mesma peça do B34 (`shared/MunicipalityPortfolioCell`, sem cópia, sem provider novo), escrevendo do lado invertido (`municipality.stateDeputies`) por uma única action de lote transacional (`setStateDeputyMunicipalitiesBatch`, lock por município, **tudo-ou-nada** — um município fora do escopo do assessor derruba o lote inteiro em vez de aplicar parcialmente em silêncio), sem piso mínimo e sem `assertMunicipalitiesWithinScope` (`canUpdateMunicipality` já escopa). Cap de `stateDeputiesArraySchema` subiu 20 → 435 para caber um lote de território inteiro do lado do município. **O critique pós-implementação achou dois P1 e a resposta foi generalizar na hora, em vez de adiar um "3º call site" hipotético:** este item **era** o 3º call site — `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31/B36) duplicavam a mesma máquina de interação (delta otimista, undo, combobox ARIA, piso/teto, split pointer-fine/coarse) com contratos incompatíveis — então `src/components/campaign/shared/RelationChipCell.tsx` nasceu como motor único e as duas células viraram wrappers finos (chips/busca/`FormData`/copy por domínio). O segundo P1 — um assessor via e tentava remover chips de município fora do escopo, falhando em silêncio no documento — fechou **escondendo** o chip: `MunicipalityPortfolioCell` agora filtra por `addableIds` os municípios que renderiza, não só os que oferece adicionar. `LeadershipStateDeputyRelationCell` ganhou `ownerName` e sua primeira suíte de testes própria (zero cobertura antes). **O `/simplify` do fechamento achou um bug latente, e ele é a razão de uma convenção daqui ser carga estrutural: `mapCampaignFormActionError` casa a mensagem lançada contra `safeMessages` por string exata**, então uma mensagem de teto escrita como literal cru no `throw` **e** em dois allowlists independentes são três chances de reescrever uma e colapsar em silêncio uma recusa real em "tente novamente". O que as expôs: o helper de delta deste item era a **terceira** cópia byte-a-byte do mesmo cálculo de um id, diferindo só no cap que lê — virou `nextIdsAfterMembership` (cap como dado, três wrappers de uma linha) e os três specs clonados viraram um `describe.each` **com mais cobertura que antes**; as mensagens viraram constantes ao lado dos seus caps (`LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`, `MUNICIPALITY_ADVISORS_CAP_MESSAGE`, `STATE_DEPUTY_STAFF_MESSAGE`). A forma "conjunto com piso e teto" ficou fora de propósito: o B34+ já havia invertido essa recomendação numa segunda leitura, e o código colapsado confirmou (genérico das duas formas pede quatro callbacks para poupar ~20 linhas; o helper novo tem 12 sem parâmetro opcional). Duas lacunas de memoização fechadas, ambas restaurando invariante que a base já tinha: `scopedPortfolioIndex` (o `.filter()` do escopo do assessor devolvia array novo por linha, derrotando o WeakMap que existe para compartilhar derivações entre linhas — fecha o F9 do B34+) e a busca da célula de dobradinhas, que renormalizava o catálogo a cada tecla. `maxItems` "por paridade" foi **rejeitado** (dobradinha não tem teto de produto; um "30 generoso" recusaria dobradinha estadual legítima). Dois débitos **absorvidos no B34+ como F10**, sem abrir ID: o laço município-a-município das duas actions de lote e a gemelaridade delas (2 call sites) — gatilho: "selecionar tudo"/multi-TI ou um terceiro lote desse lado. Sem migration/collection/Consent. Plano: [`docs/plans/chips-municipios-lista-dobradinhas.md`](../../../docs/plans/chips-municipios-lista-dobradinhas.md).
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +1,44 @@
|
|||
# Chips editáveis de municípios na lista de dobradinhas (paridade com Assessores)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Status: entregue (2026-07-28)
|
||||
Atualizado em: 2026-07-28
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B37**)
|
||||
Impeccable: B — encaixe na coluna "Municípios" de `/campanha/dobradinhas`; sem rota nova
|
||||
Appetite: ~0,75–1 dia eng — 1 componente de célula novo (Popover + Command), 2 server actions + schema + helper puro, ajuste no loader da lista; sem migration. (Menor que o B34 análogo: sem piso mínimo, sem `assertMunicipalitiesWithinScope`, lock por município em vez de por documento.)
|
||||
Responsável: —
|
||||
|
||||
## Entregue (2026-07-28) — as-built
|
||||
|
||||
**A célula é a mesma peça do B34, sem cópia.** Este item não construiu `StateDeputyMunicipalitiesCell` nem um provider novo — plugou `shared/MunicipalityPortfolioCell` (já promovida pelo B34 ✓) direto na coluna "Municípios" de `/campanha/dobradinhas`, com as costuras que o B34 já desenhou para isso: sem `minItems` (uma dobradinha pode ficar sem município — o piso deste plano nunca existiu no schema, e o comentário permanece correto), sem `maxItems` do lado da dobradinha (o teto que existe é por município — `MAX_STATE_DEPUTIES_PER_MUNICIPALITY`, inalterado), `addableIds` só quando o ator é `advisor`. O container herda **integralmente** a decisão do B34: sempre editável em `pointer-fine` (chips + "×" no hover/foco + input de busca na própria linha), Drawer em `pointer-coarse` — nenhuma decisão de container nova foi tomada aqui, e o "conflito com o pedido literal" registrado abaixo já estava fechado antes deste item começar (ver B34 ✓ e a nota B34/B36/B37 em `codebase-map.mdc`).
|
||||
|
||||
**Generalização feita nesta entrega, não adiada para um "3º call site" futuro.** Este plano registrava a extração de um `RelationshipChipEditor` agnóstico em "Adiado com gatilho", esperando B31+B34+este landarem primeiro. Na crítica pós-implementação (`/simplify` + `/impeccable`) ficou visível que **este item já era o 3º call site**: `MunicipalityPortfolioCell` (município) e `LeadershipStateDeputyRelationCell` (dobradinha↔liderança, B31 ✓/B36 ✓) tinham interfaces incompatíveis para o mesmo padrão (delta otimista, undo, combobox ARIA, piso/teto, split pointer-fine/coarse) — P1 do critique. A resposta foi generalizar imediatamente em vez de registrar um quarto "adiado com gatilho": `src/components/campaign/shared/RelationChipCell.tsx` (813 linhas) nasceu como a máquina de interação única, e `MunicipalityPortfolioCell` (816 → ~180 linhas) e `LeadershipStateDeputyRelationCell` (450 → ~185 linhas) foram reduzidos a wrappers finos — chips/busca/`FormData`/copy por domínio, nada de interação duplicada. `LeadershipStateDeputyRelationCell` ganhou `ownerName` (para o Drawer e os `aria-label`) e perdeu sua própria implementação de Popover+Command, sem mudar contrato para os dois call sites existentes (`liderancas/page.tsx`, `dobradinhas/page.tsx`) além de passar o novo prop.
|
||||
|
||||
**Segundo P1 do critique, também resolvido nesta entrega:** um assessor via — e tentava remover — chips de municípios fora do seu escopo, e a remoção falhava em silêncio no nível do documento (`canUpdateMunicipality` recusa, mas a célula não sabia explicar por quê). `MunicipalityPortfolioCell` agora filtra `municipalityIds` por `addableIds` quando presente (`visibleIds`), então um assessor só vê — e só pode tentar remover — municípios que administra; staff sem `addableIds` continua vendo e removendo todos os vínculos. Trade-off aceito: em uma janela rara de reatribuição de carteira, um piso client-side poderia recusar uma remoção que o servidor aceitaria — aceitável porque o servidor segue sendo a fonte de verdade e o pedido explícito era esconder, não apenas explicar.
|
||||
|
||||
**Escrita:** `setStateDeputyMunicipalitiesBatch` — uma única action de lote (não a dupla delta+lote do plano), transacional, com lock por município (`municipality-state-deputies:{id}`, um por município tocado, ordenado) e leitura+escrita de `municipality.stateDeputies` com `overrideAccess: false` (o acesso real é `canUpdateMunicipality`; sem `assertMunicipalitiesWithinScope`, como planejado). **Falha em lote é tudo-ou-nada**: se qualquer município do lote está fora do escopo do assessor ou não existe, a transação inteira é revertida com uma mensagem única — decisão explícita desta sessão (rejeita a alternativa "aplica o que pode, ignora o resto", que deixaria o assessor sem saber quais municípios silenciosamente falharam). `nextStateDeputyMunicipalityIdsAfterMembership` (em `src/lib/municipalityStateDeputyMembership.ts`, puro) espelha `nextAdvisorIdsAfterMembership`.
|
||||
|
||||
**Cap de 20 → 435 em `stateDeputiesArraySchema`** (`src/lib/schemas/municipality.ts`): o teto original de 20 dobradinhas por município era um vestígio de outra era do dado; com a coluna agora permitindo lote de território/ZE (até 435 municípios de uma vez do lado do dobradinha), o teto do lado do município precisava cobrir o universo inteiro em vez de truncar silenciosamente um lote grande. `MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE` foi ajustado e reexportado como mensagem segura da nova action.
|
||||
|
||||
**Loader:** `StateDeputyRowViewModel.municipalityCount` (número) → `municipalityIDs: number[]` — não o `{ id, name, slug }[]` do plano. Mesma razão do B34: a célula já recebe o catálogo completo (435 municípios) via `municipalityIndex`, então nome/slug/território resolvem no cliente e o loader não repete strings por linha.
|
||||
|
||||
**Sem provider de contexto** (mesma decisão do B34): `municipalityIndex` e `addableIds` viajam como props da factory de colunas que o RSC monta uma vez por tabela.
|
||||
|
||||
**Testes:** unit da lib pura (`municipalityStateDeputyMembership.unit.spec.ts`), int da action de lote (`campaignStateDeputyMunicipalityMembership.int.spec.ts` — escopo do assessor, teto, lock, tudo-ou-nada, no-op idempotente), e uma nova suite unit dedicada a `LeadershipStateDeputyRelationCell` (`leadershipStateDeputyRelationCell.unit.spec.ts`) que não existia antes desta entrega — o componente tinha zero cobertura própria.
|
||||
|
||||
**Gate:** `tsc`/`lint`/`format:check`/`check:cycles` limpos; unit 651/651, int 452/452 (suítes completas, não só os arquivos tocados); Aikido 0 findings nos arquivos de primeira parte editados; `knip` bloqueado pela limitação pré-existente e já ledgerada de não conseguir carregar `payload.config.ts` (P3 em `TECH-DEBT.md`) — não uma regressão desta entrega.
|
||||
|
||||
### `/simplify` do fechamento (2026-07-28) — o que mudou depois do as-built acima
|
||||
|
||||
**O achado que vale guardar é um bug latente, não uma dedução:** `nextStateDeputyIdsAfterMunicipalityMembership` nasceu aqui como a **terceira** cópia byte-a-byte do mesmo delta de um id (as três diferiam só no teto que leem), então o algoritmo passou a viver uma vez em `src/lib/relationMembershipDelta.ts` e os três nomes de domínio são wrappers de uma linha. O efeito colateral é que duas das mensagens de teto eram **literais crus repetidos em três lugares de `src` cada** — a lançada mais dois allowlists independentes — e `mapCampaignFormActionError` casa a mensagem lançada contra `safeMessages` por **string exata**: reescrever uma delas colapsaria silenciosamente uma recusa real em "tente novamente". Viraram constantes ao lado dos seus caps, seguindo o `MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE` que esta entrega já havia ajustado nos mesmos arquivos. A mensagem de staff da dobradinha tinha a mesma exposição em quatro lugares (e `actions/stateDeputy.ts` já **importava** a constante enquanto reescrevia o literal uma linha abaixo), então é `STATE_DEPUTY_STAFF_MESSAGE` — renomeada, porque guarda create/update também, não só o lote. Os três specs clonados viraram um `describe.each`, e a cobertura subiu: cada relação recebeu os casos "pode ir a zero" e "não muta a entrada" que só o clone deste item tinha. Unit 651 → **715**.
|
||||
|
||||
Na action de lote: o slug da dobradinha deixou de ser resolvido antes do laço (ele existe só para mirar o `revalidate`, que o chamador já pula quando nada mudou — o guard que o gêmeo `setLeadershipMunicipalitiesMembershipRecord` já tinha), o segundo dedup redundante saiu (o `.transform` do schema já deduplica; o `sort` fica, e o comentário dele foi corrigido: quem garante ordem de lock é `acquireTextAdvisoryLocks`, que ordena as próprias chaves), e o `435` cru que este item passou a dividir com o lote de assessores é `MAX_MUNICIPALITIES_PER_BATCH`.
|
||||
|
||||
Duas lacunas de memoização fechadas, ambas restaurando invariantes que o código já tinha em outro lugar: o `.filter()` do escopo do assessor devolvia um array novo **por linha**, justamente derrotando o WeakMap que existe para compartilhar derivações entre linhas (`scopedPortfolioIndex` memoiza por `(index, addableIds)`, fechando o **F9** do B34+), e a busca do `LeadershipStateDeputyRelationCell` chamava `matchesAtWordStart` por candidato, pagando um passe NFD e três regexes Unicode dos dois lados a cada tecla sobre o catálogo de lideranças — o custo que o próprio docstring de `wordStartFilter` avisa; agora normaliza a query uma vez contra rótulos cacheados, como `municipalityPortfolio` já fazia.
|
||||
|
||||
**Rejeitado com motivo:** `maxItems` na célula de dobradinhas, sugerido "por paridade" com o B34. Uma dobradinha não tem teto de produto de municípios (é por isso que o bound é o catálogo), então um "30 generoso" inventaria uma regra que recusa uma dobradinha estadual legítima, e o 435 honesto é inalcançável quando os ids são únicos.
|
||||
|
||||
**Adiado com gatilho, absorvido no B34+ como fase F10** (não abriu ID novo): o laço município-a-município das duas actions de lote do lado do município (`findByID` + `update` por município — ~870 round trips no teto de 435, mas ~52 no maior TI real, e nenhum teste acima de lote 2) **e** a gemelaridade das duas actions (2 call sites, abaixo do limiar de 3). Gatilho comum: uma afordância de "selecionar tudo"/multi-TI, ou um terceiro lote do lado do município.
|
||||
|
||||
## ⚠️ Conflito com o pedido literal — leia antes de implementar
|
||||
|
||||
O pedido de produto pede um **botão de "modo de edição"** para a coluna inteira, espelhando o toggle "Editar" de `/campanha/assessores` (`AdvisorsTable`). No mesmo dia (2026-07-25), essa **mesma classe de pedido** já foi resolvida quatro vezes na direção oposta, em três tabelas diferentes: **B28**/**B31**/**B32** (lista de lideranças) e **B34** (a mesma pergunta, para a coluna "Municípios" de lideranças). O argumento se repete porque a estrutura se repete: o toggle de `AdvisorsTable` existe porque **todas** as colunas daquela tela são editáveis (nome/e-mail/celular/carteira); em `/campanha/dobradinhas`, só "Municípios" seria editável — "Nome", "Partido" e "Lideranças" continuam leitura. Este plano **segue o precedente travado** (Popover disparado pela célula, não modo de tabela) em vez do texto literal do pedido. Todas as demais funcionalidades pedidas — chips, link para o município, adicionar por digitação, remover, lote de território/ZE — estão contempladas; só o **container** muda. _(proposto — validar com produto; se a mesa realmente quiser o toggle de tabela aqui, é a exceção que reabre B28/B31/B32/B34 também — mudar um muda todos, porque o argumento é o mesmo.)_
|
||||
|
|
|
|||
|
|
@ -54,11 +54,21 @@ Contra a escada de cache do repo: o degrau 1 (`cache()`) rende **zero** — é u
|
|||
|
||||
**Correção ao F1:** a opção (b) "payload mínimo" encolhe o payload RSC de **35.510 B / 7.286 B gzip** para **13.610 B / 3.534 B gzip**, mas **não poupa tempo de servidor nenhum** (10,7 ms de qualquer jeito). A decisão registrada é payload-contra-bundle, não um ganho de servidor.
|
||||
|
||||
## F9 — O ramo do assessor entrega uma chave nova ao WeakMap por linha
|
||||
## F9 ✓ — O ramo do assessor entrega uma chave nova ao WeakMap por linha
|
||||
|
||||
`searchIndex` filtra `municipalityIndex` quando há `addableIds`, produzindo **um array novo por linha** — e é esse array que vira a chave do WeakMap de derivações. O assessor fica com 25 conjuntos de derivação (4 Maps cada) em vez de um, e paga o build O(435) de novo na primeira busca de cada linha: **1,89 ms** para as 25 contra 0,076 ms para uma. É exatamente o modo de falha que a memoização foi escrita para evitar, embora o número absoluto seja pequeno.
|
||||
**Fechado no `/simplify` do B37 ✓ (2026-07-28), pela segunda saída — e ela não ficou dentro da célula.** `searchIndex` filtrava `municipalityIndex` quando há `addableIds`, produzindo **um array novo por linha**, e era esse array que virava a chave do WeakMap de derivações: 25 conjuntos (4 Maps cada) em vez de um, mais o build O(435) repetido na primeira busca de cada linha (**1,89 ms** para as 25 contra 0,076 ms para uma).
|
||||
|
||||
Duas saídas, nenhuma feita agora: passar `addableIds` como opção de `searchMunicipalityPortfolio` (preserva a contagem escopada de hits de TI/ZE e elimina as 25 alocações, mas mexe na assinatura de uma função compartilhada), ou memoizar o filtro num WeakMap aninhado dentro da célula (contido, mas é uma segunda camada de cache).
|
||||
O filtro passou a ser `scopedPortfolioIndex(index, addableIds)` em `src/lib/municipalityPortfolio.ts`, memoizado num WeakMap aninhado por `(index, addableIds)` — as duas chaves são estáveis por request (o índice é montado uma vez pelo RSC; o `Set` de escopo também), então todas as linhas voltam a receber **o mesmo array** e a memoização de derivações volta a funcionar como escrita. Mora na lib, não na célula, porque a alternativa "WeakMap dentro do componente" seria uma segunda camada de cache ao lado da que já existe no mesmo módulo — e porque a primeira saída (`addableIds` como opção de `searchMunicipalityPortfolio`) muda a assinatura de uma função compartilhada para resolver um problema de identidade de array, que é o que um WeakMap já resolve.
|
||||
|
||||
## F10 — O lote do lado do município escreve município por município (absorvido do B37 ✓)
|
||||
|
||||
Entrou por triage do `/simplify` do **B37 ✓** (2026-07-28), como fase desta família e não como item novo, porque a função que a fase discute — `setAdvisorMunicipalitiesBatchRecord` — já é nomeada no F3 acima.
|
||||
|
||||
`setStateDeputyMunicipalitiesBatchRecord` e `setAdvisorMunicipalitiesBatchRecord` percorrem a relação **invertida** (o array mora em `municipality.advisors`/`municipality.stateDeputies`), e cada município do lote custa **um `findByID` + um `update`** com `overrideAccess: false` — a leitura existe porque o acesso por linha e por campo do Payload é o que escopa o assessor, e o update precisa do array inteiro. Os locks **não** são o problema (o C8 ✓ já os bateu em 1 round trip via `unnest`); o laço é. No teto do schema (435, elevado pelo próprio B37 para caber um TI inteiro) são ~870 round trips numa transação, cada um pagando ~1–3 ms de `gru1`↔`sa-east-1` — e o int spec mais gordo do caminho usa lote de 2, então o comportamento acima de um punhado de municípios é **inferido, não medido**.
|
||||
|
||||
Não foi corrigido no fechamento porque o teto é inalcançável pelo produto de hoje: o maior lote que a UI oferece é um chip de território, e o maior TI da Bahia tem **26** municípios (Litoral Sul — medido em `bahiaTerritories.ts`, ~52 round trips). **O gatilho é uma afordância de "selecionar tudo" / "aplicar a vários TIs"** — quem construir isso mede antes: ou reescreve o laço em SQL bulk sobre a transação do Payload (precedente `supporterImportBulk.ts`, que já faz `payload.db.drizzle` em chunks dentro da transação aberta), **ou** mantém o laço e trava o lote num limite honesto abaixo do universo. Reescrever em bulk custa a checagem de escopo que o `overrideAccess: false` dá de graça hoje — ela teria de ser reimplementada explicitamente contra `getAdvisorMunicipalityIds`, que é exatamente o tipo de reimplementação de access que o repo evita, então a medição vem antes da escolha.
|
||||
|
||||
**Segunda metade, com o mesmo gatilho: as duas actions são gêmeas (2 call sites).** Fora o nome do campo, do lock e da mensagem, são a mesma sequência: parse, dedup+sort, transação, `reloadStaffActor`, locks em bloco, laço de leitura escopada + delta puro + update, coleta de slugs mudados, revalidate por delta. Está **abaixo** do limiar de 3 do `AGENTS.md`, e — diferente do F3, onde o algoritmo era idêntico — aqui o que se repete é uma sequência de orquestração cuja extração pediria parametrizar "de que lado da aresta eu escrevo", a fontanaria genérica que o F3 já rejeitou uma vez. Registrado para que um **terceiro** lote do lado do município não nasça como terceira cópia: quem o escrever extrai `withMunicipalitySideRelationBatch({ relation, lockPrefix, computeNext })` primeiro, e fecha esta metade junto com a de cima.
|
||||
|
||||
## F1 — Regressão de First Load JS nas duas listas (prioritário)
|
||||
|
||||
|
|
@ -84,9 +94,13 @@ O staff edita muito e navega pouco, o que favorece **(b)** — o bundle é pago
|
|||
|
||||
Um terceiro lance, **ortogonal e cumulativo**: carregar o `Drawer` por `next/dynamic` atrás do primeiro toque. Ele só abre em `pointer-coarse`, e uma gaveta que já anima pode absorver o import. Recupera parte dos +42 kB que atingem as duas rotas.
|
||||
|
||||
## F2 — `useChipRowOverflow`: o gatilho disparou e a máquina foi copiada
|
||||
## F2 ✓ — `useChipRowOverflow`: o gatilho disparou e a máquina foi copiada
|
||||
|
||||
`MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31 ✓) carregam ~105 linhas idênticas de medição de overflow — as constantes, o guard de "não trate o primeiro callback do observer como resize", a invalidação por conteúdo, o filtro `lastVisibleTop + 1`, o laço de largura da cauda, o `expandToggle` com `tabIndex={measuring ? -1 : undefined}` — incluindo um comentário verbatim. As duas células são renderizadas **pela mesma tabela**.
|
||||
**Fechado no B37 ✓ (2026-07-28), e a previsão desta fase estava errada — vale registrar por quê.** Estava escrito aqui que "**o B37 não retira este débito**: B37 pluga uma terceira relação na célula compartilhada sem máquina nova; quem fecha o F2 é quem deletar `LeadershipStateDeputyRelationCell`". O critique pós-implementação do B37 leu a mesma evidência ao contrário: se as duas células rodam na mesma tabela com a mesma máquina escrita duas vezes e contratos incompatíveis, então **o B37 já era o 3º call site** — e a resposta foi generalizar na hora em vez de registrar um quarto adiamento. `src/components/campaign/shared/RelationChipCell.tsx` nasceu como o motor único e as duas células viraram wrappers finos (`MunicipalityPortfolioCell` 816 → ~180 linhas; `LeadershipStateDeputyRelationCell` 450 → ~185), então as ~165 linhas que esta fase mediu **não foram deduplicadas num hook, foram deletadas** — não há mais duas cópias de medição, adoção por `sameIdSet`, `expandToggle` nem redutor otimista para extrair. O módulo do qual a cópia sobreviveu continua existindo, mas sem máquina dentro: o que resta nele é domínio (chips com sufixo de partido, `FormData`, copy).
|
||||
|
||||
Não reabrir como "extrair `useChipRowOverflow`": a medição vive uma vez, dentro do motor, e um hook separado só faria sentido para um terceiro consumidor que **não** seja uma célula de chips.
|
||||
|
||||
**Descrição original (histórico):** `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31 ✓) carregavam ~105 linhas idênticas de medição de overflow — as constantes, o guard de "não trate o primeiro callback do observer como resize", a invalidação por conteúdo, o filtro `lastVisibleTop + 1`, o laço de largura da cauda, o `expandToggle` com `tabIndex={measuring ? -1 : undefined}` — incluindo um comentário verbatim. As duas células são renderizadas **pela mesma tabela**.
|
||||
|
||||
A duplicação é **anterior** ao B34 (nasceu entre `AdvisorMunicipalityCell` e a célula do B31; esta entrega moveu uma das cópias para `shared/`), mas o `AGENTS.md` do B34 escreveu que "**ele**, não o B37, é o gatilho de extração" — e o gatilho disparou. Extrair `useChipRowOverflow({ chipsKey, chipAttribute, toggleAttribute, trailingWidth, gapPx, enabled })` para `shared/`; a única diferença real entre os dois chamadores é o que ocupa o slot final (um botão de lápis com `ref` contra o `min-width` calculado do input), que vira o parâmetro.
|
||||
|
||||
|
|
@ -94,9 +108,17 @@ A duplicação é **anterior** ao B34 (nasceu entre `AdvisorMunicipalityCell` e
|
|||
|
||||
**O `/simplify` do B32+ ✓ mexeu nesta célula e mudou duas coisas para esta fase (2026-07-28).** A região `aria-live` dela estava **dentro** do `PopoverContent` — o bug exato que o B32+ F4 corrigiu nos outros cinco controles, e que aqui deixava a falha e o "Salvando…" mudos justamente depois de fechar, que é quando a gravação acontece — e foi içada para fora do `<Popover>`, com `role="status"`, já nesta forma: **a migração não pode reintroduzi-la dentro do overlay.** E o argumento de custo virou: `CampaignCellEditOverlay` já é usado por seis chamadores, então a parte "trigger + Popover" desta célula deixou de ser trabalho de extração e passou a ser adoção de casca pronta (inclusive o `initialFocus` e o `aria-busy` que ela não tem) — o que sobra de verdade nesta fase é a máquina de chips e o redutor otimista. Um detalhe da adoção, medido no B34: a folha do container é um `Drawer` sempre montado e reporta `onOpenChange(false)` quando **qualquer** dismiss varre a pilha de camadas, não só quando ela fecha.
|
||||
|
||||
## F3 — Um só delta de membership
|
||||
## F3 — Um só delta de membership (metade fechada)
|
||||
|
||||
`nextMunicipalityIdsAfterLeadershipMembership` é o **terceiro** módulo da família, e a generalização foi **verificada exata**, não aproximada: chamá-lo com um array de um elemento reproduz a semântica dos dois irmãos (`nextStateDeputyIdsAfterMembership`, `nextAdvisorIdsAfterMembership`) — `assigned === alreadyAssigned → null` equivale a `added.length === 0 → null`, e o teto checado antes do append equivale ao checado depois para um id. A única capacidade que os irmãos não têm é o piso, que já é opcional na forma. Colapsar em `nextRelationMembershipIds(current, changed, assigned, { max, capMessage, min, floorMessage })`, com os três nomes como wrappers de uma linha se o vocabulário importar; os três specs unitários viram um.
|
||||
**A metade dos três deltas de um id foi fechada no `/simplify` do B37 ✓ (2026-07-28), e a recomendação invertida da segunda leitura se confirmou.** `nextStateDeputyIdsAfterMunicipalityMembership` (nascido no B37) era a **terceira** cópia byte-a-byte do mesmo algoritmo de um id — os três diferiam só no teto que leem —, então ele vive uma vez em `src/lib/relationMembershipDelta.ts` (`nextIdsAfterMembership(current, id, assigned, { max, message })`) e os três nomes de domínio são wrappers de uma linha ligando o seu próprio cap. Os três specs clonados viraram um `describe.each`, e a cobertura **subiu**: cada relação agora recebe os casos "pode ir a zero" e "não muta a entrada" que só o clone do B37 tinha, mais a asserção explícita de que cada wrapper recusa no **seu** teto com a **sua** mensagem.
|
||||
|
||||
**`nextMunicipalityIdsAfterLeadershipMembership` ficou de fora de propósito** — é a forma "um conjunto com piso e teto", e a segunda leitura registrada acima (um genérico cobrindo as duas formas precisaria de quatro callbacks para poupar ~20 linhas) continua valendo depois de ver o código colapsado: o helper novo tem 12 linhas e nenhum parâmetro opcional.
|
||||
|
||||
**Efeito colateral que era um bug latente, e a razão de o pareamento importar:** duas das mensagens de teto eram literais crus repetidos em três lugares de `src` cada (lançada + dois allowlists independentes), e `mapCampaignFormActionError` casa mensagem lançada contra `safeMessages` por **string exata** — reescrever uma delas colapsaria silenciosamente uma recusa real em "tente novamente". Viraram constantes ao lado dos seus caps (`LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`, `MUNICIPALITY_ADVISORS_CAP_MESSAGE`), seguindo o precedente `MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE` que já existia nos mesmos arquivos. A mensagem de staff da dobradinha tinha a mesma exposição em quatro lugares — e `actions/stateDeputy.ts` já **importava** a constante enquanto reescrevia o literal uma linha abaixo — então é `STATE_DEPUTY_STAFF_MESSAGE` (renomeada: guarda create/update também, não só o lote), e a de conflito que ela divide com a rota de criação também tem nome.
|
||||
|
||||
**Continua aberto nesta fase:** o `withLeadershipRelationDelta` abaixo (política de escrita por delta sob lock consultivo numa linha de liderança).
|
||||
|
||||
**Descrição original (histórico):** `nextMunicipalityIdsAfterLeadershipMembership` é o **terceiro** módulo da família, e a generalização foi **verificada exata**, não aproximada: chamá-lo com um array de um elemento reproduz a semântica dos dois irmãos (`nextStateDeputyIdsAfterMembership`, `nextAdvisorIdsAfterMembership`) — `assigned === alreadyAssigned → null` equivale a `added.length === 0 → null`, e o teto checado antes do append equivale ao checado depois para um id. A única capacidade que os irmãos não têm é o piso, que já é opcional na forma. Colapsar em `nextRelationMembershipIds(current, changed, assigned, { max, capMessage, min, floorMessage })`, com os três nomes como wrappers de uma linha se o vocabulário importar; os três specs unitários viram um.
|
||||
|
||||
No mesmo arquivo, `setLeadershipMunicipalitiesMembershipRecord` e `setLeadershipStateDeputyMembershipRecord` são ~55 de ~90 linhas iguais e **adjacentes**, com três comentários copiados palavra por palavra. Um `withLeadershipRelationDelta({ leadershipId, relation, lockKey, computeNext })` — local ao arquivo, não genérico entre collections — dono da transação, do lock, da leitura escopada e do early return de no-op, deixando ao chamador a asserção de escopo e a busca de slug. Precedente declarado: `runStaffEntityMutation` (política no helper, mutação tipada no chamador). São 2 call sites, abaixo do limiar de 3 — mas "escrita por delta sob lock consultivo numa linha de liderança" é uma política que merece nome, que é a outra metade da mesma regra.
|
||||
|
||||
|
|
@ -106,14 +128,15 @@ No mesmo arquivo, `setLeadershipMunicipalitiesMembershipRecord` e `setLeadership
|
|||
|
||||
`leadershipData.ts` estreitou certo (`municipalityIDs: number[]`), mas `AdvisorsTable` faz `row.municipalities.map((m) => m.id)` e joga `name`/`slug` fora — enquanto `municipalitiesByAdvisorIds` ainda roda um `payload.find` inteiro sobre `municipality` para produzi-los. A mesma decisão tomada de dois jeitos opostos na mesma entrega. Dar ao loader **de lista** um caminho só-ids (a forma `{id,name,slug}` continua para `assessores/[id]`, que de fato renderiza os nomes). Se o F1 (b) entrar, cai junto: o cliente resolve tudo pelo catálogo.
|
||||
|
||||
## F5 — Combobox à mão onde o kit já embrulha um
|
||||
## F5 — Combobox à mão onde o kit já embrulha um (escopo reduzido pelo B37 ✓)
|
||||
|
||||
São ~90 linhas de teclado/ARIA mais ~20 de atributos duplicados entre as duas superfícies. **A restrição que descartaria a alternativa não se aplica:** o cmdk `Command` está corretamente fora (ele resolve itens consultando `[cmdk-item]` dentro da própria raiz, e o listbox aqui **precisa** ser portalizado porque o container de scroll da tabela recorta uma lista em fluxo) — mas `ui/combobox.tsx`, que embrulha o `Combobox` do base-ui, já aceita **`anchor`** e tem um hook `data-chips={!!anchor}`, ou seja, foi escrito para exatamente esta forma: input dentro de uma linha de chips, listbox portalizado ancorado na célula. Fit parcial (hits heterogêneos — município/TI/ZE — e a recusa por teto pedem render de item próprio de qualquer jeito), então remove a metade de teclado/ARIA, não o todo.
|
||||
**A metade "duplicada" morreu com o `RelationChipCell` (B37 ✓, 2026-07-28):** os ~20 atributos repetidos entre as duas superfícies e as ~16 linhas de `searchInputProps` do F6 não existem mais — o input de busca e o listbox são escritos **uma vez**, no motor. O que resta são as ~90 linhas de teclado/ARIA num único lugar, o que **melhora** a economia da troca em vez de piorá-la: adotar `ui/combobox.tsx` passou a ser uma edição, não duas mantidas em sincronia. O argumento abaixo (fit parcial, `anchor` já previsto, cmdk corretamente fora) continua íntegro.
|
||||
|
||||
**Descrição original (histórico):** eram ~90 linhas de teclado/ARIA mais ~20 de atributos duplicados entre as duas superfícies. **A restrição que descartaria a alternativa não se aplica:** o cmdk `Command` está corretamente fora (ele resolve itens consultando `[cmdk-item]` dentro da própria raiz, e o listbox aqui **precisa** ser portalizado porque o container de scroll da tabela recorta uma lista em fluxo) — mas `ui/combobox.tsx`, que embrulha o `Combobox` do base-ui, já aceita **`anchor`** e tem um hook `data-chips={!!anchor}`, ou seja, foi escrito para exatamente esta forma: input dentro de uma linha de chips, listbox portalizado ancorado na célula. Fit parcial (hits heterogêneos — município/TI/ZE — e a recusa por teto pedem render de item próprio de qualquer jeito), então remove a metade de teclado/ARIA, não o todo.
|
||||
|
||||
## F6 — Baixos
|
||||
|
||||
- **`PortfolioChipLabel`**: o ramo `chip.kind === 'municipality' ? <Badge asChild><Link/></Badge> : <Badge/>` está escrito duas vezes (~14 linhas) nas duas superfícies. O resto da divergência entre elas é a tese da entrega (16 px revelado no hover contra 44 px sempre visível) e deve continuar divergindo.
|
||||
- **`searchInputProps(surface)`**: o input de busca está escrito nas duas superfícies com **oito** props idênticas (`role`, `aria-expanded`, `aria-controls`, `aria-autocomplete`, `aria-activedescendant`, `onKeyDown`, `aria-label` e o corpo `setQuery` + `setFeedback(null)`), divergindo só no `setOpen(true)`/`onFocus`/`onBlur` do inline. A divergência é **correta**, e é justamente por isso que deveria ser explícita: uma factory espalhada nos dois, com os overrides do inline escritos depois do spread, poupa ~16 linhas e troca uma assimetria inferida por uma declarada.
|
||||
- ~~**`PortfolioChipLabel`**~~ ✓ e ~~**`searchInputProps(surface)`**~~ ✓ — os dois eram "escrito duas vezes nas duas superfícies", e as duas superfícies passaram a ser **uma** com o `RelationChipCell` (B37 ✓): o rótulo do chip é um `renderChipLabel` do motor e o input de busca é montado uma vez. Nada a extrair.
|
||||
- **e2e**: `createStaffLeadership({ role, municipalities })` e `expectPostResponse(page, fragment)` em `campaignE2EFixtures.ts` — 3 call sites hoje.
|
||||
- **`relationIds()`** exportado de `tests/helpers/campaignFixtures.ts`: `typeof entry === 'number' ? entry : entry.id` está reescrito em 9 lugares nos int specs, com `src/utilities/relationship.ts` já exportando o equivalente de produção. Débito anterior; o spec novo é a 9ª instância.
|
||||
- **`loadMunicipalityPortfolioIndex` fora da escada de cache**: leitura do catálogo inteiro, de campos só-geografia, idêntica para todo ator e toda request, sem `cache()` nem `unstable_cache`. Uma chamada por request hoje, então o degrau 1 não compra nada — mas o degrau 2 é a resposta da escada. Decidir, não deixar por omissão.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache'
|
|||
|
||||
import { updateStateDeputy } from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import { nullableFormText, requiredRelationshipFormValue } from '@/lib/formData'
|
||||
import { STATE_DEPUTY_STAFF_MESSAGE } from '@/lib/schemas/stateDeputy'
|
||||
import {
|
||||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
|
|
@ -23,6 +24,6 @@ export const updateStateDeputyFormAction = async (
|
|||
revalidatePath('/campanha/dobradinhas/[slug]', 'page')
|
||||
return { message: 'Dobradinha atualizada.' }
|
||||
},
|
||||
safeMessages: ['Somente a coordenação e a assessoria gerenciam dobradinhas.'],
|
||||
safeMessages: [STATE_DEPUTY_STAFF_MESSAGE],
|
||||
genericMessage: 'Não foi possível salvar a dobradinha. Verifique seu acesso e tente novamente.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
'use server'
|
||||
|
||||
import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import { requiredFormBoolean, requiredRelationshipFormValue } from '@/lib/formData'
|
||||
import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership'
|
||||
import { setStateDeputyMunicipalitiesBatch } from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import {
|
||||
repeatedRelationshipFormValues,
|
||||
requiredFormBoolean,
|
||||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
import { LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE } from '@/lib/schemas/leadership'
|
||||
import { STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES } from '@/lib/schemas/stateDeputy'
|
||||
import {
|
||||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
|
|
@ -10,7 +16,7 @@ import {
|
|||
|
||||
const safeMessages = [
|
||||
'Somente a coordenação e a assessoria podem gerenciar lideranças.',
|
||||
`Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`,
|
||||
LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
] as const
|
||||
|
||||
/** One chip toggle in the "Lideranças" column of `/campanha/dobradinhas` (B36). */
|
||||
|
|
@ -30,3 +36,26 @@ export const setLeadershipStateDeputyMembershipFormAction = async (
|
|||
safeMessages,
|
||||
genericMessage: 'Não foi possível atualizar as lideranças. Tente novamente.',
|
||||
})
|
||||
|
||||
/**
|
||||
* Add or remove municipalities in the "Municípios" column of
|
||||
* `/campanha/dobradinhas` (B37) — one chip or a whole território/ZE. `ownerId`
|
||||
* is the field name `MunicipalityPortfolioCell` sends, so the same cell
|
||||
* serves lideranças, assessores and dobradinhas.
|
||||
*/
|
||||
export const setStateDeputyMunicipalitiesFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await setStateDeputyMunicipalitiesBatch({
|
||||
stateDeputyId: requiredRelationshipFormValue(formData, 'ownerId'),
|
||||
municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
|
||||
assigned: requiredFormBoolean(formData, 'assigned'),
|
||||
})
|
||||
return { message: 'Municípios atualizados.' }
|
||||
},
|
||||
safeMessages: STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
|
||||
genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
import { createStateDeputy } from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import { optionalFormText, requiredFormText } from '@/lib/formData'
|
||||
import {
|
||||
STATE_DEPUTY_CONFLICT_MESSAGE,
|
||||
STATE_DEPUTY_STAFF_MESSAGE,
|
||||
} from '@/lib/schemas/stateDeputy'
|
||||
import {
|
||||
runCampaignRedirectFormAction,
|
||||
type CampaignFormActionState,
|
||||
|
|
@ -19,10 +23,7 @@ export const createStateDeputyFormAction = async (
|
|||
notes: optionalFormText(formData, 'notes'),
|
||||
}),
|
||||
redirectTo: (stateDeputy) => `/campanha/dobradinhas/${stateDeputy.slug}`,
|
||||
safeMessages: [
|
||||
'Já existe uma dobradinha com este nome.',
|
||||
'Somente a coordenação e a assessoria gerenciam dobradinhas.',
|
||||
],
|
||||
safeMessages: [STATE_DEPUTY_CONFLICT_MESSAGE, STATE_DEPUTY_STAFF_MESSAGE],
|
||||
genericMessage:
|
||||
'Não foi possível cadastrar a dobradinha. Verifique os dados e tente novamente.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,11 +10,16 @@ import {
|
|||
CampaignListResults,
|
||||
CampaignTransitionAnchor,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
|
||||
import {
|
||||
CampaignTable,
|
||||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import {
|
||||
LeadershipStateDeputyRelationCell,
|
||||
type RelationCellOption,
|
||||
} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
|
||||
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { StateDeputyFilters } from '@/components/campaign/stateDeputy/StateDeputyFilters'
|
||||
import { StateDeputySortableHead } from '@/components/campaign/stateDeputy/StateDeputySortableHead'
|
||||
|
|
@ -27,11 +32,13 @@ import {
|
|||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getAdvisorMunicipalityIds, isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { loadLeadershipOptions } from '@/utilities/campaignRelationOptions'
|
||||
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipalityPortfolioIndex'
|
||||
import {
|
||||
loadStateDeputyListPageData,
|
||||
type StateDeputyRowViewModel,
|
||||
|
|
@ -50,7 +57,10 @@ import {
|
|||
type StateDeputyListState,
|
||||
} from '@/utilities/stateDeputyListUrl'
|
||||
|
||||
import { setLeadershipStateDeputyMembershipFormAction } from './formActions'
|
||||
import {
|
||||
setLeadershipStateDeputyMembershipFormAction,
|
||||
setStateDeputyMunicipalitiesFormAction,
|
||||
} from './formActions'
|
||||
|
||||
type StateDeputiesPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
|
|
@ -86,6 +96,8 @@ const stateDeputyColumns = (
|
|||
partyFilterOptions: StateDeputyFilterOption[],
|
||||
hasNoPartyOption: boolean,
|
||||
leadershipOptions: RelationCellOption[],
|
||||
municipalityIndex: MunicipalityPortfolioIndexEntry[],
|
||||
addableMunicipalityIds: ReadonlySet<number> | undefined,
|
||||
): Array<CampaignTableColumn<StateDeputyRowViewModel>> => [
|
||||
{
|
||||
id: 'name',
|
||||
|
|
@ -118,8 +130,24 @@ const stateDeputyColumns = (
|
|||
{
|
||||
id: 'municipalities',
|
||||
label: 'Municípios',
|
||||
cellClassName: 'tabular-nums',
|
||||
cell: (row) => row.municipalityCount,
|
||||
head: (
|
||||
<CampaignTableHead description="Edite aqui: passe o mouse em um chip para remover, ou busque para adicionar. Um território ou ZE entra e sai como um bloco.">
|
||||
Municípios
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: 'max-w-64 whitespace-normal',
|
||||
cell: (row) => (
|
||||
<MunicipalityPortfolioCell
|
||||
ownerId={row.id}
|
||||
ownerName={row.name}
|
||||
municipalityIds={row.municipalityIDs}
|
||||
municipalityIndex={municipalityIndex}
|
||||
{...(addableMunicipalityIds ? { addableIds: addableMunicipalityIds } : {})}
|
||||
commitAction={setStateDeputyMunicipalitiesFormAction}
|
||||
drawerTitle="Municípios da dobradinha"
|
||||
updateErrorMessage="Não foi possível atualizar os municípios."
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'leaderships',
|
||||
|
|
@ -129,6 +157,7 @@ const stateDeputyColumns = (
|
|||
<LeadershipStateDeputyRelationCell
|
||||
direction="fromStateDeputy"
|
||||
fixedId={row.id}
|
||||
ownerName={row.name}
|
||||
items={row.leaderships.map((leadership) => ({
|
||||
id: leadership.id,
|
||||
label: leadership.name,
|
||||
|
|
@ -150,9 +179,16 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
if (!user) redirect('/campanha/login')
|
||||
if (!isCampaignStaff(user)) redirect('/campanha')
|
||||
|
||||
const [{ rows, totalDocs, totalPages, filterFacets }, leadershipOptions] = await Promise.all([
|
||||
const [
|
||||
{ rows, totalDocs, totalPages, filterFacets },
|
||||
leadershipOptions,
|
||||
municipalityIndex,
|
||||
administeredIds,
|
||||
] = await Promise.all([
|
||||
loadStateDeputyListPageData(payload, user, canonicalUrl.state),
|
||||
loadLeadershipOptions(payload, user),
|
||||
loadMunicipalityPortfolioIndex(payload),
|
||||
user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
|
||||
])
|
||||
const resolvedUrl = resolveStateDeputyListUrl(rawSearchParams, totalPages)
|
||||
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
|
||||
|
|
@ -175,6 +211,8 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
href: `/campanha/liderancas/${option.id}`,
|
||||
},
|
||||
})),
|
||||
municipalityIndex,
|
||||
administeredIds ? new Set(administeredIds) : undefined,
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -183,8 +221,8 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Dobradinhas</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Deputados estaduais com quem a campanha dobra — vincule lideranças na própria lista e
|
||||
municípios nas fichas correspondentes.
|
||||
Deputados estaduais com quem a campanha dobra — vincule lideranças e municípios direto
|
||||
na lista.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild className="min-h-11">
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
MAX_LEADERSHIP_STATE_DEPUTIES,
|
||||
LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import {
|
||||
runCampaignFormAction,
|
||||
|
|
@ -23,7 +23,7 @@ import {
|
|||
|
||||
const safeMessages = [
|
||||
...leadershipStaffEditSafeMessages,
|
||||
`Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`,
|
||||
LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ const leadershipColumns = ({
|
|||
<LeadershipStateDeputyRelationCell
|
||||
direction="fromLeadership"
|
||||
fixedId={row.id}
|
||||
ownerName={row.name}
|
||||
items={row.stateDeputies.map((deputy) => ({
|
||||
id: deputy.id,
|
||||
label: deputy.name,
|
||||
|
|
|
|||
|
|
@ -1,22 +1,32 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership'
|
||||
import {
|
||||
STATE_DEPUTY_CONFLICT_MESSAGE,
|
||||
STATE_DEPUTY_STAFF_MESSAGE,
|
||||
stateDeputyCreateSchema,
|
||||
stateDeputyMunicipalitiesBatchSchema,
|
||||
stateDeputyUpdateSchema,
|
||||
type StateDeputyCreateInput,
|
||||
type StateDeputyMunicipalitiesBatchInput,
|
||||
type StateDeputyUpdateInput,
|
||||
} from '@/lib/schemas/stateDeputy'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
||||
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
|
||||
import { runStaffEntityMutation, type StaffEntityPolicy } from '@/utilities/campaignEntityActions'
|
||||
import { hookFilledCreateData } from '@/utilities/hookFilledData'
|
||||
import { revalidateMunicipalityListPaths } from '@/utilities/municipalityRevalidation'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
|
||||
import { uniqueRelationshipIds } from '@/utilities/relationship'
|
||||
|
||||
const stateDeputyPolicy: StaffEntityPolicy = {
|
||||
staffMessage: 'Somente a coordenação e a assessoria gerenciam dobradinhas.',
|
||||
staffMessage: STATE_DEPUTY_STAFF_MESSAGE,
|
||||
conflictPattern: /state_deputy_(name|slug)|duplicate key/i,
|
||||
conflictMessage: 'Já existe uma dobradinha com este nome.',
|
||||
conflictMessage: STATE_DEPUTY_CONFLICT_MESSAGE,
|
||||
}
|
||||
|
||||
const createStateDeputyRecord = async (
|
||||
|
|
@ -63,3 +73,129 @@ export const updateStateDeputy = async (input: StateDeputyUpdateInput) => {
|
|||
const { payload, actor } = await getCampaignActionContext()
|
||||
return updateStateDeputyRecord(payload, actor, input)
|
||||
}
|
||||
|
||||
const revalidateStateDeputyMunicipalityPaths = (
|
||||
stateDeputySlug: string | undefined,
|
||||
municipalitySlugs: readonly string[],
|
||||
) => {
|
||||
revalidatePath('/campanha/dobradinhas', 'page')
|
||||
if (stateDeputySlug) {
|
||||
revalidatePath(`/campanha/dobradinhas/${stateDeputySlug}`, 'page')
|
||||
}
|
||||
revalidateMunicipalityListPaths({ scope: 'list' })
|
||||
for (const slug of municipalitySlugs) {
|
||||
revalidateMunicipalityListPaths({ slug, scope: 'detail' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta write for the "Municípios" column of `/campanha/dobradinhas` (B37) —
|
||||
* adds or removes this `stateDeputy` on `municipality.stateDeputies` for a
|
||||
* batch of municipalities (one chip, or a whole território/ZE). Mirrors
|
||||
* `setAdvisorMunicipalitiesBatchRecord`: many municípios get touched, not one
|
||||
* owning document. Unlike that twin, this write does NOT bypass access —
|
||||
* `canUpdateMunicipality` already scopes an advisor to administered
|
||||
* municípios at the document level, and `stateDeputies`' field access
|
||||
* (`canManageCampaignStaffField`) is staff-wide, so `overrideAccess: false`
|
||||
* on both the read and the write is what refuses an out-of-scope município.
|
||||
*/
|
||||
export const setStateDeputyMunicipalitiesBatchRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: StateDeputyMunicipalitiesBatchInput,
|
||||
) => {
|
||||
const { stateDeputyId, municipalityIds, assigned } =
|
||||
stateDeputyMunicipalitiesBatchSchema.parse(input)
|
||||
// Already deduped by the schema's `.transform`; sorted only so a batch applies
|
||||
// and reports in a stable order — `acquireTextAdvisoryLocks` sorts its own keys,
|
||||
// so the deadlock-avoidance ordering does not depend on this line.
|
||||
const uniqueMunicipalityIds = [...municipalityIds].sort((left, right) => left - right)
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await reloadStaffActor(payload, actor, STATE_DEPUTY_STAFF_MESSAGE, req)
|
||||
|
||||
await acquireTextAdvisoryLocks(
|
||||
payload,
|
||||
req,
|
||||
uniqueMunicipalityIds.map((id) => `municipality-state-deputies:${id}`),
|
||||
)
|
||||
|
||||
// Every município actually changed, not just the last one — same bug
|
||||
// class the B34 twin fixed: a território chip can touch up to 435
|
||||
// municípios in one call, and revalidating only the last would leave
|
||||
// the rest of the detail pages stale.
|
||||
const changedSlugs: string[] = []
|
||||
|
||||
for (const municipalityId of uniqueMunicipalityIds) {
|
||||
// Row + field access verify the município is in the actor's scope
|
||||
// (`canUpdateMunicipality` for advisors, `canManageCampaignStaffField`
|
||||
// for the field) — an out-of-scope município throws here.
|
||||
const municipality = await payload.findByID({
|
||||
collection: 'municipality',
|
||||
id: municipalityId,
|
||||
depth: 0,
|
||||
select: { stateDeputies: true, slug: true },
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
|
||||
const currentStateDeputyIDs = uniqueRelationshipIds(municipality.stateDeputies)
|
||||
const nextStateDeputyIDs = nextStateDeputyIdsAfterMunicipalityMembership(
|
||||
currentStateDeputyIDs,
|
||||
stateDeputyId,
|
||||
assigned,
|
||||
)
|
||||
if (nextStateDeputyIDs === null) continue
|
||||
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: municipalityId,
|
||||
data: { stateDeputies: nextStateDeputyIDs },
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
if (typeof municipality.slug === 'string') changedSlugs.push(municipality.slug)
|
||||
}
|
||||
|
||||
// Resolved AFTER the loop, and only when something changed: the slug
|
||||
// exists solely to target the revalidate, which the caller skips on a
|
||||
// no-op batch — so on a no-op this read was pure waste (the guard the
|
||||
// `setLeadershipMunicipalitiesMembershipRecord` twin already had).
|
||||
// Intentional admin bypass: existence is otherwise enforced by Payload's
|
||||
// relationship validation on `update` (precedent:
|
||||
// `setLeadershipStateDeputyMembershipRecord`, B31).
|
||||
const stateDeputySlug =
|
||||
changedSlugs.length > 0
|
||||
? (
|
||||
await payload.findByID({
|
||||
collection: 'stateDeputy',
|
||||
id: stateDeputyId,
|
||||
depth: 0,
|
||||
select: { slug: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
).slug
|
||||
: undefined
|
||||
|
||||
return { stateDeputySlug, slugs: changedSlugs }
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível atualizar os municípios da dobradinha.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const setStateDeputyMunicipalitiesBatch = async (
|
||||
input: StateDeputyMunicipalitiesBatchInput,
|
||||
) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const result = await setStateDeputyMunicipalitiesBatchRecord(payload, actor, input)
|
||||
if (result.slugs.length > 0) {
|
||||
revalidateStateDeputyMunicipalityPaths(result.stateDeputySlug, result.slugs)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
'use client'
|
||||
|
||||
import { PencilIcon, XIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { sameIdSet } from '@/lib/sameIdSet'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { matchesAtWordStart } from '@/lib/wordStartFilter'
|
||||
import {
|
||||
RelationChipCell,
|
||||
type RelationChip,
|
||||
type RelationChipCellCopy,
|
||||
type RelationSearchHit,
|
||||
} from '@/components/campaign/shared/RelationChipCell'
|
||||
import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
export type RelationCellItem = {
|
||||
|
|
@ -32,6 +29,8 @@ type LeadershipStateDeputyRelationDirection = 'fromLeadership' | 'fromStateDeput
|
|||
type LeadershipStateDeputyRelationCellProps = {
|
||||
direction: LeadershipStateDeputyRelationDirection
|
||||
fixedId: number
|
||||
/** Whose relation this is — spoken in the aria-labels and the Drawer. */
|
||||
ownerName: string
|
||||
items: RelationCellItem[]
|
||||
options: RelationCellOption[]
|
||||
membershipAction: (
|
||||
|
|
@ -42,351 +41,176 @@ type LeadershipStateDeputyRelationCellProps = {
|
|||
measureOverflow?: boolean
|
||||
}
|
||||
|
||||
const COLLAPSED_CHIP_ROWS = 3
|
||||
/** Matches `gap-1` on the rest chip row. */
|
||||
const CHIP_GAP_PX = 4
|
||||
|
||||
const COPY = {
|
||||
fromLeadership: {
|
||||
editLabel: 'Editar dobradinhas',
|
||||
title: 'Atribuir dobradinhas',
|
||||
description: 'Deputados estaduais em dobradinha com esta liderança.',
|
||||
searchPlaceholder: 'Buscar deputado estadual…',
|
||||
searchAriaLabel: 'Buscar deputado estadual',
|
||||
saving: 'Salvando dobradinhas.',
|
||||
savingAria: 'Salvando dobradinhas',
|
||||
saveError: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
|
||||
},
|
||||
fromStateDeputy: {
|
||||
editLabel: 'Editar lideranças',
|
||||
title: 'Atribuir lideranças',
|
||||
description: 'Lideranças em dobradinha com este deputado estadual.',
|
||||
searchPlaceholder: 'Buscar liderança…',
|
||||
searchAriaLabel: 'Buscar liderança',
|
||||
saving: 'Salvando lideranças.',
|
||||
savingAria: 'Salvando lideranças',
|
||||
saveError: 'Não foi possível atualizar as lideranças. Tente novamente.',
|
||||
},
|
||||
} as const
|
||||
|
||||
const itemLabel = (item: RelationCellItem): string =>
|
||||
item.party ? `${item.label} (${item.party})` : item.label
|
||||
|
||||
/**
|
||||
* Bidirectional cell for the `leadership.stateDeputies` edge.
|
||||
* Both directions write `leadershipId`/`stateDeputyId`/`assigned` through the
|
||||
* same membership action.
|
||||
* Normalized search labels, once per `options` array rather than per row: the
|
||||
* RSC builds one array and every row of the table gets that same reference, so a
|
||||
* `WeakMap` shares the work where a per-row `useMemo` would repeat it (same
|
||||
* reasoning as `municipalityPortfolio`'s derivation cache).
|
||||
*/
|
||||
const normalizedLabelsByOptions = new WeakMap<readonly RelationCellOption[], Map<number, string>>()
|
||||
|
||||
const normalizedOptionLabels = (options: readonly RelationCellOption[]): Map<number, string> => {
|
||||
const cached = normalizedLabelsByOptions.get(options)
|
||||
if (cached) return cached
|
||||
|
||||
const normalized = new Map(
|
||||
options.map((option) => [option.id, normalizeSearchPhrase(option.searchLabel)] as const),
|
||||
)
|
||||
normalizedLabelsByOptions.set(options, normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
const DIRECTION_COPY: Record<
|
||||
LeadershipStateDeputyRelationDirection,
|
||||
{
|
||||
drawerTitle: string
|
||||
triggerVerb: string
|
||||
emptyDrawerMessage: string
|
||||
searchPlaceholder: string
|
||||
searchLabel: string
|
||||
suggestionsLabel: string
|
||||
savingMessage: string
|
||||
savedMessage: string
|
||||
removedMessage: (count: number) => string
|
||||
updateErrorMessage: string
|
||||
}
|
||||
> = {
|
||||
fromLeadership: {
|
||||
drawerTitle: 'Dobradinhas da liderança',
|
||||
triggerVerb: 'dobradinhas',
|
||||
emptyDrawerMessage: 'Nenhuma dobradinha vinculada.',
|
||||
searchPlaceholder: 'Buscar deputado estadual…',
|
||||
searchLabel: 'Buscar deputado estadual',
|
||||
suggestionsLabel: 'Sugestões de deputados estaduais',
|
||||
savingMessage: 'Salvando dobradinhas.',
|
||||
savedMessage: 'Dobradinhas salvas.',
|
||||
removedMessage: (count) =>
|
||||
count === 1 ? 'Dobradinha removida.' : `${count} dobradinhas removidas.`,
|
||||
updateErrorMessage: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
|
||||
},
|
||||
fromStateDeputy: {
|
||||
drawerTitle: 'Lideranças da dobradinha',
|
||||
triggerVerb: 'lideranças',
|
||||
emptyDrawerMessage: 'Nenhuma liderança vinculada.',
|
||||
searchPlaceholder: 'Buscar liderança…',
|
||||
searchLabel: 'Buscar liderança',
|
||||
suggestionsLabel: 'Sugestões de lideranças',
|
||||
savingMessage: 'Salvando lideranças.',
|
||||
savedMessage: 'Lideranças salvas.',
|
||||
removedMessage: (count) =>
|
||||
count === 1 ? 'Liderança removida.' : `${count} lideranças removidas.`,
|
||||
updateErrorMessage: 'Não foi possível atualizar as lideranças. Tente novamente.',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Bidirectional `leadership.stateDeputies` edge, on the same `RelationChipCell`
|
||||
* that serves município portfolios (B37). Both directions write
|
||||
* `leadershipId`/`stateDeputyId`/`assigned` through the same membership action;
|
||||
* every chip here is a single item — no batch, no floor/ceiling.
|
||||
*/
|
||||
export const LeadershipStateDeputyRelationCell = ({
|
||||
direction,
|
||||
fixedId,
|
||||
ownerName,
|
||||
items,
|
||||
options,
|
||||
membershipAction,
|
||||
measureOverflow = true,
|
||||
}: LeadershipStateDeputyRelationCellProps) => {
|
||||
const copy = COPY[direction]
|
||||
const [open, setOpen] = useState(false)
|
||||
const [current, setCurrent] = useState<RelationCellItem[]>(items)
|
||||
const [query, setQuery] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const lastPropsRef = useRef(items)
|
||||
const copy = DIRECTION_COPY[direction]
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [collapsedHeight, setCollapsedHeight] = useState<number | null>(null)
|
||||
/** `null` means "render every chip so the next effect can measure them". */
|
||||
const [visibleChipCount, setVisibleChipCount] = useState<number | null>(null)
|
||||
const [measureToken, setMeasureToken] = useState(0)
|
||||
const lastRowWidth = useRef(0)
|
||||
const chipRowRef = useRef<HTMLDivElement>(null)
|
||||
const editRef = useRef<HTMLButtonElement>(null)
|
||||
const itemById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items])
|
||||
// Additions resolve through `options` (the addable catalog), same asymmetry
|
||||
// as the município cell's `optionById`: only what can still be added needs to
|
||||
// be looked up there, and a removal always has its item in `items` already.
|
||||
const optionItemById = useMemo(
|
||||
() => new Map(options.map((option) => [option.id, option.item])),
|
||||
[options],
|
||||
)
|
||||
|
||||
// Adopt server props only when they change from outside (navigation / RSC
|
||||
// refresh) — an in-flight delta's optimistic state must not be clobbered by
|
||||
// a prop identical in content to what we already had.
|
||||
useEffect(() => {
|
||||
const propIds = items.map((item) => item.id)
|
||||
if (
|
||||
sameIdSet(
|
||||
propIds,
|
||||
lastPropsRef.current.map((item) => item.id),
|
||||
)
|
||||
)
|
||||
return
|
||||
lastPropsRef.current = items
|
||||
setCurrent(items)
|
||||
}, [items])
|
||||
|
||||
const currentIds = useMemo(() => new Set(current.map((item) => item.id)), [current])
|
||||
const optionById = useMemo(() => new Map(options.map((option) => [option.id, option])), [options])
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!open) return []
|
||||
return options.filter(
|
||||
(option) => !currentIds.has(option.id) && matchesAtWordStart(option.searchLabel, query),
|
||||
)
|
||||
}, [open, options, currentIds, query])
|
||||
|
||||
const invalidateMeasurement = useCallback(() => {
|
||||
setVisibleChipCount(null)
|
||||
setMeasureToken((token) => token + 1)
|
||||
}, [])
|
||||
|
||||
const chipsKey = current.map((item) => item.id).join('|')
|
||||
useEffect(() => {
|
||||
if (!measureOverflow) return
|
||||
invalidateMeasurement()
|
||||
}, [chipsKey, measureOverflow, invalidateMeasurement])
|
||||
|
||||
useEffect(() => {
|
||||
if (!measureOverflow) return
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
lastRowWidth.current = row.clientWidth
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (row.clientWidth === lastRowWidth.current) return
|
||||
lastRowWidth.current = row.clientWidth
|
||||
invalidateMeasurement()
|
||||
})
|
||||
observer.observe(row)
|
||||
return () => observer.disconnect()
|
||||
}, [measureOverflow, invalidateMeasurement])
|
||||
|
||||
useEffect(() => {
|
||||
if (!measureOverflow || visibleChipCount !== null) return
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
|
||||
const chipElements = [...row.querySelectorAll<HTMLElement>('[data-relation-chip]')]
|
||||
if (chipElements.length === 0) {
|
||||
setVisibleChipCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
const rowRect = row.getBoundingClientRect()
|
||||
const chipRects = chipElements.map((element) => element.getBoundingClientRect())
|
||||
const firstChip = chipRects[0]
|
||||
if (!firstChip) {
|
||||
setVisibleChipCount(0)
|
||||
return
|
||||
}
|
||||
const chipHeight = firstChip.height
|
||||
setCollapsedHeight(chipHeight * COLLAPSED_CHIP_ROWS + CHIP_GAP_PX * (COLLAPSED_CHIP_ROWS - 1))
|
||||
|
||||
const lastVisibleTop = firstChip.top + (chipHeight + CHIP_GAP_PX) * (COLLAPSED_CHIP_ROWS - 1)
|
||||
let fitting = chipRects.filter((rect) => rect.top <= lastVisibleTop + 1).length
|
||||
|
||||
// Reserve room on the last visible line for "Ver mais…" (and the sibling pencil).
|
||||
const toggleWidth =
|
||||
row.querySelector<HTMLElement>('[data-relation-toggle]')?.getBoundingClientRect().width ?? 0
|
||||
const pencilWidth = editRef.current?.getBoundingClientRect().width ?? 0
|
||||
const trailingWidth = toggleWidth + CHIP_GAP_PX + pencilWidth
|
||||
while (fitting > 0 && fitting < chipElements.length) {
|
||||
const trailing = chipRects[fitting - 1]
|
||||
if (!trailing || trailing.right + CHIP_GAP_PX + trailingWidth <= rowRect.right) break
|
||||
fitting -= 1
|
||||
}
|
||||
|
||||
setVisibleChipCount(fitting)
|
||||
}, [measureOverflow, measureToken, visibleChipCount])
|
||||
|
||||
const toggle = (itemId: number, assigned: boolean) => {
|
||||
// The removed summary is captured up front (not re-derived from the
|
||||
// catalog) so a failed removal can be undone even for an item that has
|
||||
// since dropped out of `options` — same asymmetry as the `optionById`
|
||||
// lookup below, which only needs to resolve *additions*.
|
||||
const removedItem = assigned ? undefined : current.find((item) => item.id === itemId)
|
||||
if (assigned && !optionById.has(itemId)) return
|
||||
|
||||
setErrorMessage(null)
|
||||
setCurrent((previous) => {
|
||||
if (assigned) {
|
||||
if (previous.some((item) => item.id === itemId)) return previous
|
||||
const option = optionById.get(itemId)
|
||||
if (!option) return previous
|
||||
return [...previous, option.item]
|
||||
const buildChips = useCallback(
|
||||
(ids: number[]): RelationChip[] => {
|
||||
const chips: RelationChip[] = []
|
||||
for (const id of ids) {
|
||||
const item = itemById.get(id) ?? optionItemById.get(id)
|
||||
if (!item) continue
|
||||
chips.push({ key: String(id), label: itemLabel(item), href: item.href, ids: [id] })
|
||||
}
|
||||
return previous.filter((item) => item.id !== itemId)
|
||||
})
|
||||
setQuery('')
|
||||
return chips
|
||||
},
|
||||
[itemById, optionItemById],
|
||||
)
|
||||
|
||||
const formData = new FormData()
|
||||
if (direction === 'fromLeadership') {
|
||||
formData.set('leadershipId', String(fixedId))
|
||||
formData.set('stateDeputyId', String(itemId))
|
||||
} else {
|
||||
formData.set('leadershipId', String(itemId))
|
||||
formData.set('stateDeputyId', String(fixedId))
|
||||
}
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
const searchHits = useCallback(
|
||||
(query: string, assignedIds: ReadonlySet<number>): RelationSearchHit[] => {
|
||||
// Normalize the query ONCE and read pre-normalized labels, instead of an
|
||||
// NFD pass plus three Unicode-property regexes per candidate per
|
||||
// keystroke — the cost `wordStartFilter`'s own doc comment warns about,
|
||||
// and which matters here because `options` in the `fromStateDeputy`
|
||||
// direction is the whole (unpaginated) leadership catalog.
|
||||
const normalizedQuery = normalizeSearchPhrase(query)
|
||||
const normalizedLabels = normalizedOptionLabels(options)
|
||||
const hits: RelationSearchHit[] = []
|
||||
for (const option of options) {
|
||||
if (assignedIds.has(option.id)) continue
|
||||
if (!matchesNormalizedAtWordStart(normalizedLabels.get(option.id) ?? '', normalizedQuery)) {
|
||||
continue
|
||||
}
|
||||
hits.push({ key: String(option.id), label: option.searchLabel, ids: [option.id] })
|
||||
}
|
||||
return hits
|
||||
},
|
||||
[options],
|
||||
)
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await membershipAction({}, formData)
|
||||
if (result.status === 'success') return
|
||||
// Undo only this delta — not the whole cell back to `lastPropsRef` —
|
||||
// so a failed toggle can't wipe an earlier toggle from the same batch
|
||||
// that already saved successfully.
|
||||
setCurrent((previous) =>
|
||||
assigned
|
||||
? previous.filter((item) => item.id !== itemId)
|
||||
: removedItem
|
||||
? [...previous, removedItem]
|
||||
: previous,
|
||||
)
|
||||
const message = result.message ?? copy.saveError
|
||||
setErrorMessage(message)
|
||||
toast.error(message)
|
||||
})
|
||||
}
|
||||
const buildFormData = useCallback(
|
||||
(changedIds: number[], assigned: boolean) => {
|
||||
const itemId = changedIds[0]
|
||||
const formData = new FormData()
|
||||
if (direction === 'fromLeadership') {
|
||||
formData.set('leadershipId', String(fixedId))
|
||||
formData.set('stateDeputyId', String(itemId))
|
||||
} else {
|
||||
formData.set('leadershipId', String(itemId))
|
||||
formData.set('stateDeputyId', String(fixedId))
|
||||
}
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
return formData
|
||||
},
|
||||
[direction, fixedId],
|
||||
)
|
||||
|
||||
const statusMessage = errorMessage ?? (isPending ? copy.saving : '')
|
||||
|
||||
const showAllChips = !measureOverflow || expanded
|
||||
const measuring = measureOverflow && visibleChipCount === null
|
||||
const hasHiddenChips =
|
||||
measureOverflow && visibleChipCount !== null && visibleChipCount < current.length
|
||||
const visibleItems = (() => {
|
||||
if (!measureOverflow || showAllChips || measuring || visibleChipCount === null) return current
|
||||
return current.slice(0, visibleChipCount)
|
||||
})()
|
||||
/**
|
||||
* Only the pre-measurement render needs a height cap; once the chip list is
|
||||
* trimmed the row is three lines tall on its own.
|
||||
*/
|
||||
const clamping = measuring && !showAllChips
|
||||
const chipRowStyle =
|
||||
clamping && collapsedHeight !== null ? { maxHeight: collapsedHeight } : undefined
|
||||
|
||||
// Mounted while measuring so width is reserved; hidden until overflow is known.
|
||||
const expandToggle =
|
||||
measureOverflow && (measuring || hasHiddenChips) ? (
|
||||
<button
|
||||
type="button"
|
||||
data-relation-toggle
|
||||
tabIndex={measuring ? -1 : undefined}
|
||||
aria-hidden={measuring || undefined}
|
||||
className={cn(
|
||||
'px-1 text-xs font-medium text-primary underline-offset-4 hover:underline',
|
||||
measuring && 'invisible pointer-events-none',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setExpanded((previous) => !previous)
|
||||
}}
|
||||
>
|
||||
{showAllChips ? 'Ver menos' : 'Ver mais…'}
|
||||
</button>
|
||||
) : null
|
||||
const chipCellCopy = useMemo<RelationChipCellCopy>(
|
||||
() => ({
|
||||
searchPlaceholder: copy.searchPlaceholder,
|
||||
searchLabel: copy.searchLabel,
|
||||
suggestionsLabel: copy.suggestionsLabel,
|
||||
emptyDrawerMessage: copy.emptyDrawerMessage,
|
||||
savingMessage: copy.savingMessage,
|
||||
savedMessage: copy.savedMessage,
|
||||
removedMessage: copy.removedMessage,
|
||||
}),
|
||||
[copy],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div
|
||||
ref={chipRowRef}
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-1',
|
||||
measureOverflow && current.length > 0 ? 'min-w-0 flex-1' : undefined,
|
||||
clamping && 'overflow-hidden',
|
||||
)}
|
||||
style={chipRowStyle}
|
||||
>
|
||||
{current.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
) : (
|
||||
visibleItems.map((item) => (
|
||||
<Badge key={item.id} variant="outline" asChild>
|
||||
<Link href={item.href} data-relation-chip className="max-w-full truncate">
|
||||
{itemLabel(item)}
|
||||
</Link>
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
{expandToggle}
|
||||
</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
ref={editRef}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={copy.editLabel}
|
||||
className={cn(
|
||||
'inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
open ? 'bg-muted/60 text-foreground' : undefined,
|
||||
)}
|
||||
>
|
||||
<PencilIcon className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 p-0">
|
||||
<div className="relative flex flex-col gap-2 p-3 pb-0">
|
||||
<p className="text-sm font-medium">{copy.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{copy.description}</p>
|
||||
{isPending ? (
|
||||
<Spinner
|
||||
className="absolute top-3 right-3 size-3.5 text-muted-foreground"
|
||||
aria-label={copy.savingAria}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{current.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 px-3 pt-2">
|
||||
{current.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-label={`Remover ${item.label}`}
|
||||
onClick={() => toggle(item.id, false)}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
|
||||
>
|
||||
<span className="truncate">{itemLabel(item)}</span>
|
||||
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Command shouldFilter={false} className="mt-1">
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder={copy.searchPlaceholder}
|
||||
aria-label={copy.searchAriaLabel}
|
||||
/>
|
||||
<CommandList>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum resultado.
|
||||
</p>
|
||||
) : (
|
||||
filteredOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={`${direction}-${option.id}`}
|
||||
onSelect={() => toggle(option.id, true)}
|
||||
>
|
||||
<span className="truncate">{option.searchLabel}</span>
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/*
|
||||
* Outside the Popover on purpose (B32+ F4): a region that unmounts with
|
||||
* the overlay announces nothing once the overlay closes, and closing is
|
||||
* what commits here. This cell still hand-rolls its trigger and popover —
|
||||
* migrating it onto `CampaignCellEditOverlay` is B31's extraction trigger.
|
||||
*/}
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{statusMessage}
|
||||
</p>
|
||||
</div>
|
||||
<RelationChipCell
|
||||
ownerId={fixedId}
|
||||
ownerName={ownerName}
|
||||
ids={items.map((item) => item.id)}
|
||||
buildChips={buildChips}
|
||||
searchHits={searchHits}
|
||||
buildFormData={buildFormData}
|
||||
commitAction={membershipAction}
|
||||
drawerTitle={copy.drawerTitle}
|
||||
triggerLabel={`Editar ${copy.triggerVerb} de ${ownerName}`}
|
||||
updateErrorMessage={copy.updateErrorMessage}
|
||||
copy={chipCellCopy}
|
||||
measureOverflow={measureOverflow}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,69 +1,53 @@
|
|||
'use client'
|
||||
|
||||
import { MapPinIcon, XIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
type KeyboardEvent,
|
||||
} from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { MapPinIcon } from 'lucide-react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover'
|
||||
import {
|
||||
RelationChipCell,
|
||||
type RelationChip,
|
||||
type RelationChipCellCopy,
|
||||
type RelationSearchHit,
|
||||
} from '@/components/campaign/shared/RelationChipCell'
|
||||
import {
|
||||
buildMunicipalityPortfolioChips,
|
||||
scopedPortfolioIndex,
|
||||
searchMunicipalityPortfolio,
|
||||
type MunicipalityPortfolioChip,
|
||||
type MunicipalityPortfolioIndexEntry,
|
||||
type MunicipalityPortfolioSearchHit,
|
||||
} from '@/lib/municipalityPortfolio'
|
||||
import { sameIdSet } from '@/lib/sameIdSet'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
const COLLAPSED_CHIP_ROWS = 3
|
||||
/** Matches `gap-1.5` on the chip row, and `max-h-18` (72px) is this math at a 20px chip. */
|
||||
const CHIP_GAP_PX = 6
|
||||
|
||||
const SEARCH_PLACEHOLDER = 'Buscar município, território ou ZE…'
|
||||
const SEARCH_LABEL = 'Buscar município, território de identidade ou zona eleitoral'
|
||||
/** Distinct from `SEARCH_LABEL`: two controls must not share one accessible name. */
|
||||
const SUGGESTIONS_LABEL = 'Sugestões de municípios'
|
||||
|
||||
const hitDescription = (hit: MunicipalityPortfolioSearchHit): string => {
|
||||
if (hit.kind === 'municipality') return 'Município'
|
||||
if (hit.kind === 'territory') return `Território · ${hit.count} municípios`
|
||||
return `Zona eleitoral · ${hit.count} municípios`
|
||||
}
|
||||
|
||||
const resultsMessage = (count: number): string => {
|
||||
if (count === 0) return 'Nenhum resultado.'
|
||||
return count === 1 ? '1 resultado.' : `${count} resultados.`
|
||||
}
|
||||
const toRelationChip = (chip: MunicipalityPortfolioChip): RelationChip =>
|
||||
chip.kind === 'territory'
|
||||
? {
|
||||
key: chip.key,
|
||||
label: chip.label,
|
||||
icon: <MapPinIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />,
|
||||
trailingLabel: String(chip.municipalityIds.length),
|
||||
ids: chip.municipalityIds,
|
||||
removalSuffix: ` — ${chip.municipalityIds.length} municípios`,
|
||||
}
|
||||
: {
|
||||
key: chip.key,
|
||||
label: chip.label,
|
||||
href: `/campanha/municipios/${chip.slug}`,
|
||||
ids: [chip.municipalityId],
|
||||
}
|
||||
|
||||
const withDelta = (
|
||||
base: readonly number[],
|
||||
changedIds: readonly number[],
|
||||
assigned: boolean,
|
||||
): number[] => {
|
||||
const next = new Set(base)
|
||||
for (const id of changedIds) {
|
||||
if (assigned) next.add(id)
|
||||
else next.delete(id)
|
||||
}
|
||||
return [...next]
|
||||
}
|
||||
|
||||
type SuggestionSurface = 'inline' | 'drawer'
|
||||
const toRelationHit = (hit: MunicipalityPortfolioSearchHit): RelationSearchHit => ({
|
||||
key: hit.key,
|
||||
label: hit.label,
|
||||
description: hitDescription(hit),
|
||||
ids: hit.kind === 'municipality' ? [hit.municipalityId] : hit.municipalityIds,
|
||||
})
|
||||
|
||||
type MunicipalityPortfolioCellProps = {
|
||||
/** `null` in draft mode (a row that does not exist yet). */
|
||||
|
|
@ -80,12 +64,7 @@ type MunicipalityPortfolioCellProps = {
|
|||
addableIds?: ReadonlySet<number>
|
||||
/** Floor the relation enforces server-side — 1 for `leadership.municipalities`. */
|
||||
minItems?: number
|
||||
/**
|
||||
* Ceiling the relation enforces server-side. Checked here so a território that
|
||||
* would blow the cap is refused with the reason BEFORE the optimistic apply —
|
||||
* the server rejection would otherwise revert a whole batch with a message
|
||||
* that never says which limit was hit.
|
||||
*/
|
||||
/** Ceiling the relation enforces server-side. */
|
||||
maxItems?: number
|
||||
/** Draft mode (new row): keep changes local and report them upward. */
|
||||
draft?: boolean
|
||||
|
|
@ -99,6 +78,12 @@ type MunicipalityPortfolioCellProps = {
|
|||
updateErrorMessage: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Município specialization of `RelationChipCell`: territory/ZE chips collapse a
|
||||
* complete membership into one batch chip, and every município chip links to
|
||||
* its detail page. All of the interaction machine (optimistic delta, undo,
|
||||
* combobox, floor/ceiling, pointer-fine/coarse split) lives in the shared cell.
|
||||
*/
|
||||
export const MunicipalityPortfolioCell = ({
|
||||
ownerId,
|
||||
ownerName,
|
||||
|
|
@ -113,701 +98,86 @@ export const MunicipalityPortfolioCell = ({
|
|||
drawerTitle,
|
||||
updateErrorMessage,
|
||||
}: MunicipalityPortfolioCellProps) => {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [optimistic, setOptimistic] = useState<number[] | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
/** `null` means "render every chip so the next effect can measure them". */
|
||||
const [visibleChipCount, setVisibleChipCount] = useState<number | null>(null)
|
||||
const [measureToken, setMeasureToken] = useState(0)
|
||||
const lastRowWidth = useRef(0)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [feedback, setFeedback] = useState<
|
||||
{ kind: 'saved' } | { kind: 'error'; message: string } | null
|
||||
>(null)
|
||||
/** Active suggestion (ARIA 1.2 combobox): index 0 is pre-selected, so Enter always picks. */
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
/** Only keyboard movement should scroll: the mouse is already where it points. */
|
||||
const activeFromKeyboard = useRef(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const chipRowRef = useRef<HTMLDivElement>(null)
|
||||
const baseId = useId()
|
||||
const listboxId = (surface: SuggestionSurface) => `${baseId}-${surface}-listbox`
|
||||
const optionId = useCallback(
|
||||
(surface: SuggestionSurface, index: number) => `${baseId}-${surface}-option-${index}`,
|
||||
[baseId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setOptimistic((current) => {
|
||||
if (!current) return null
|
||||
return sameIdSet(current, municipalityIds) ? null : current
|
||||
})
|
||||
}, [municipalityIds])
|
||||
|
||||
const effectiveIds = optimistic ?? municipalityIds
|
||||
|
||||
/**
|
||||
* The baseline a deferred delta falls back to, which cannot be the render's
|
||||
* `municipalityIds`: the toast's "Desfazer" outlives the render that created
|
||||
* it, and by the time it fires the optimistic value has usually reconciled to
|
||||
* null — so the functional updater takes the fallback branch and would rebuild
|
||||
* the row from a set that predates every edit made since. That divergence is
|
||||
* permanent, because the reconcile effect above only clears on an exact match.
|
||||
* `addableIds` also hides: an advisor's `canUpdateMunicipality` is scoped to
|
||||
* `municipality.advisors`, so a município this relation holds outside that
|
||||
* scope cannot be removed either — the write would silently no-op at the
|
||||
* document level with no way for the cell to explain why. Rather than let
|
||||
* that failure surface, the chip for it never renders for the advisor at
|
||||
* all; staff (`addableIds` undefined) still see and can remove every link.
|
||||
*/
|
||||
const latestIds = useRef(municipalityIds)
|
||||
useEffect(() => {
|
||||
latestIds.current = effectiveIds
|
||||
}, [effectiveIds])
|
||||
|
||||
const assignedIds = useMemo(() => new Set(effectiveIds), [effectiveIds])
|
||||
const chips = useMemo(
|
||||
() => buildMunicipalityPortfolioChips(effectiveIds, municipalityIndex),
|
||||
[effectiveIds, municipalityIndex],
|
||||
const visibleIds = useMemo(
|
||||
() => (addableIds ? municipalityIds.filter((id) => addableIds.has(id)) : municipalityIds),
|
||||
[municipalityIds, addableIds],
|
||||
)
|
||||
|
||||
/**
|
||||
* Suggestions are scoped, chips are not: filtering the index the search reads
|
||||
* also shrinks the território / ZE hits to what the actor may actually add,
|
||||
* instead of offering a batch the server would reject halfway.
|
||||
* instead of offering a batch the server would reject halfway. Memoized by
|
||||
* `(index, addableIds)` rather than per row so all rows share one array — see
|
||||
* `scopedPortfolioIndex`.
|
||||
*/
|
||||
const searchIndex = useMemo(
|
||||
() =>
|
||||
addableIds
|
||||
? municipalityIndex.filter((entry) => addableIds.has(entry.id))
|
||||
: municipalityIndex,
|
||||
() => scopedPortfolioIndex(municipalityIndex, addableIds),
|
||||
[addableIds, municipalityIndex],
|
||||
)
|
||||
const searching = query.trim().length > 0
|
||||
/**
|
||||
* Per surface, because Radix can close the popover with text still in the
|
||||
* input — and because the inline list is PORTALED, so `pointer-coarse:hidden`
|
||||
* on the cell would not hide it while the Drawer is up.
|
||||
*/
|
||||
const inlineSuggesting = searching && open && !drawerOpen
|
||||
const drawerSuggesting = searching && drawerOpen
|
||||
const suggesting = inlineSuggesting || drawerSuggesting
|
||||
const hits = useMemo(
|
||||
() => (suggesting ? searchMunicipalityPortfolio(query, searchIndex, assignedIds) : []),
|
||||
[suggesting, query, searchIndex, assignedIds],
|
||||
|
||||
const buildChips = useCallback(
|
||||
(ids: number[]) => buildMunicipalityPortfolioChips(ids, municipalityIndex).map(toRelationChip),
|
||||
[municipalityIndex],
|
||||
)
|
||||
|
||||
const hitsKey = hits.map((hit) => hit.key).join('|')
|
||||
useEffect(() => {
|
||||
setActiveIndex(0)
|
||||
}, [hitsKey])
|
||||
|
||||
const activeSurface: SuggestionSurface = drawerOpen ? 'drawer' : 'inline'
|
||||
useEffect(() => {
|
||||
if (!activeFromKeyboard.current) return
|
||||
document.getElementById(optionId(activeSurface, activeIndex))?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
})
|
||||
}, [activeIndex, activeSurface, optionId])
|
||||
|
||||
/**
|
||||
* The token is what makes a stale measurement recoverable: an invalidation
|
||||
* batched with a measurement would otherwise settle back on `null` without
|
||||
* changing any dependency, leaving the cell unmeasured forever.
|
||||
*/
|
||||
const invalidateMeasurement = useCallback(() => {
|
||||
setVisibleChipCount(null)
|
||||
setMeasureToken((token) => token + 1)
|
||||
}, [])
|
||||
|
||||
// Keyed by content, not array identity: a re-render that rebuilds the same
|
||||
// chips must not throw away a measurement that is still valid. The remove
|
||||
// button is absolutely positioned, so revealing it never changes a chip's box.
|
||||
const chipsKey = chips.map((chip) => chip.key).join('|')
|
||||
useEffect(() => {
|
||||
invalidateMeasurement()
|
||||
}, [chipsKey, invalidateMeasurement])
|
||||
|
||||
useEffect(() => {
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
// Seeded here so the observer's initial callback isn't read as a resize.
|
||||
lastRowWidth.current = row.clientWidth
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (row.clientWidth === lastRowWidth.current) return
|
||||
lastRowWidth.current = row.clientWidth
|
||||
invalidateMeasurement()
|
||||
})
|
||||
observer.observe(row)
|
||||
return () => observer.disconnect()
|
||||
}, [invalidateMeasurement])
|
||||
|
||||
/**
|
||||
* Runs on the frames where every chip is in the DOM, so the packing of the
|
||||
* three visible rows — and the room the toggle needs on the third one — is
|
||||
* read from the real layout instead of estimated from label lengths.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (visibleChipCount !== null) return
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
|
||||
const chipElements = [...row.querySelectorAll<HTMLElement>('[data-portfolio-chip]')]
|
||||
if (chipElements.length === 0) {
|
||||
setVisibleChipCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
const rowRect = row.getBoundingClientRect()
|
||||
const chipRects = chipElements.map((element) => element.getBoundingClientRect())
|
||||
const lastVisibleTop =
|
||||
chipRects[0].top + (chipRects[0].height + CHIP_GAP_PX) * (COLLAPSED_CHIP_ROWS - 1)
|
||||
let fitting = chipRects.filter((rect) => rect.top <= lastVisibleTop + 1).length
|
||||
|
||||
// The toggle — and the search input — must share the last visible line,
|
||||
// otherwise they wrap into a line nobody asked for. The input's box is read
|
||||
// as its `min-width`, not its rect: `flex-1` makes the rendered width the
|
||||
// leftover of the row, which would reserve the whole line. A zero-width
|
||||
// rect means the input is `display: none` (coarse pointer), where the row
|
||||
// has no trailing control to make room for at all.
|
||||
const toggleWidth =
|
||||
row.querySelector<HTMLElement>('[data-portfolio-toggle]')?.getBoundingClientRect().width ?? 0
|
||||
const inputElement = inputRef.current
|
||||
const inputWidth =
|
||||
inputElement && inputElement.getBoundingClientRect().width > 0
|
||||
? Number.parseFloat(getComputedStyle(inputElement).minWidth) || 0
|
||||
: 0
|
||||
const trailingWidth = toggleWidth + CHIP_GAP_PX + inputWidth
|
||||
while (fitting > 0 && fitting < chipElements.length) {
|
||||
const trailing = chipRects[fitting - 1]
|
||||
if (trailing.right + CHIP_GAP_PX + trailingWidth <= rowRect.right) break
|
||||
fitting -= 1
|
||||
}
|
||||
|
||||
setVisibleChipCount(fitting)
|
||||
}, [measureToken, visibleChipCount])
|
||||
|
||||
/**
|
||||
* Every delta is applied functionally, forward and back: two chips toggled in
|
||||
* the same burst — or an "Desfazer" pressed after a later add — would otherwise
|
||||
* each compute from the set their own render captured and drop the other's work.
|
||||
*/
|
||||
const commit = (changedIds: number[], assigned: boolean) => {
|
||||
if (changedIds.length === 0) return
|
||||
|
||||
if (draft) {
|
||||
// Advanced synchronously as well: the parent holds the draft in state, so
|
||||
// two toggles batched into one tick would otherwise both read the prop of
|
||||
// the render they were queued from and the second would drop the first.
|
||||
const next = withDelta(latestIds.current, changedIds, assigned)
|
||||
latestIds.current = next
|
||||
onDraftChange?.(next)
|
||||
} else {
|
||||
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, assigned))
|
||||
}
|
||||
setQuery('')
|
||||
setOpen(false)
|
||||
setFeedback(null)
|
||||
|
||||
if (draft) return
|
||||
if (ownerId === null) return
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set('ownerId', String(ownerId))
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
for (const id of changedIds) formData.append('municipalityIds', String(id))
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await commitAction({}, formData)
|
||||
if (result.status === 'success') {
|
||||
setFeedback({ kind: 'saved' })
|
||||
// A território/ZE removal drops up to 30 links in one tap, and the row
|
||||
// it left behind carries no trace of what was there. Single removals
|
||||
// are one search away from being restored and get no toast.
|
||||
if (!assigned && changedIds.length > 1) {
|
||||
toast.success(`${changedIds.length} municípios removidos.`, {
|
||||
action: { label: 'Desfazer', onClick: () => commit(changedIds, true) },
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
// Undo only THIS delta: reverting to the server baseline would also wipe
|
||||
// a sibling toggle from the same burst that already saved.
|
||||
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, !assigned))
|
||||
const message = result.message ?? updateErrorMessage
|
||||
setFeedback({ kind: 'error', message })
|
||||
toast.error(message)
|
||||
})
|
||||
}
|
||||
|
||||
const chipMunicipalityIds = (chip: MunicipalityPortfolioChip): number[] =>
|
||||
chip.kind === 'territory' ? chip.municipalityIds : [chip.municipalityId]
|
||||
|
||||
/** The relation's floor, refused here so the server never has to say no. */
|
||||
const removalFloorReason = (chip: MunicipalityPortfolioChip): string | null =>
|
||||
assignedIds.size - chipMunicipalityIds(chip).length < minItems
|
||||
? `${ownerName} precisa de pelo menos ${minItems === 1 ? 'um município' : `${minItems} municípios`}`
|
||||
: null
|
||||
|
||||
const additionCapReason = (additions: number[]): string | null =>
|
||||
maxItems !== undefined && assignedIds.size + additions.length > maxItems
|
||||
? `${ownerName} aceita no máximo ${maxItems} municípios.`
|
||||
: null
|
||||
|
||||
/** A batch chip says how much it removes, in the label a screen reader reads. */
|
||||
const chipScopeLabel = (chip: MunicipalityPortfolioChip): string =>
|
||||
chip.kind === 'territory'
|
||||
? `${chip.label} — ${chip.municipalityIds.length} municípios`
|
||||
: chip.label
|
||||
|
||||
const chipRemoveLabel = (chip: MunicipalityPortfolioChip, floorReason: string | null): string =>
|
||||
floorReason
|
||||
? `${floorReason} — não é possível remover ${chip.label}`
|
||||
: `Remover ${chipScopeLabel(chip)}`
|
||||
|
||||
/**
|
||||
* Refusals explain themselves on attempt instead of going silent: the control
|
||||
* keeps `aria-disabled` (so it stays in the tab order and can still be
|
||||
* activated) rather than `disabled`, which would hide the only copy of the
|
||||
* reason from every keyboard user.
|
||||
*/
|
||||
const attemptRemove = (chip: MunicipalityPortfolioChip) => {
|
||||
const floorReason = removalFloorReason(chip)
|
||||
if (floorReason) {
|
||||
toast.error(`${floorReason}.`)
|
||||
return
|
||||
}
|
||||
commit(chipMunicipalityIds(chip), false)
|
||||
}
|
||||
|
||||
const hitMunicipalityIds = (hit: MunicipalityPortfolioSearchHit): number[] =>
|
||||
hit.kind === 'municipality' ? [hit.municipalityId] : hit.municipalityIds
|
||||
|
||||
const pickHit = (hit: MunicipalityPortfolioSearchHit) => {
|
||||
const additions = hitMunicipalityIds(hit)
|
||||
const capReason = additionCapReason(additions)
|
||||
if (capReason) {
|
||||
toast.error(capReason)
|
||||
return
|
||||
}
|
||||
commit(additions, true)
|
||||
}
|
||||
|
||||
const closeSuggestions = () => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
/**
|
||||
* ARIA 1.2 combobox keyboard contract, shared by the inline input and the
|
||||
* Drawer's: Up/Down/Home/End move the active option, Enter picks it, Escape
|
||||
* abandons the search. Without it the suggestions are mouse-only.
|
||||
*/
|
||||
const onSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
if (hits.length === 0) return
|
||||
event.preventDefault()
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||||
activeFromKeyboard.current = true
|
||||
setActiveIndex((current) => (current + delta + hits.length) % hits.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Home' || event.key === 'End') {
|
||||
if (hits.length === 0) return
|
||||
event.preventDefault()
|
||||
activeFromKeyboard.current = true
|
||||
setActiveIndex(event.key === 'Home' ? 0 : hits.length - 1)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
const hit = hits[activeIndex]
|
||||
if (!hit) return
|
||||
event.preventDefault()
|
||||
pickHit(hit)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
if (drawerOpen) {
|
||||
setQuery('')
|
||||
return
|
||||
}
|
||||
closeSuggestions()
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
// Editing needs every chip reachable, so focusing the cell always expands it.
|
||||
const showAllChips = expanded || open
|
||||
const measuring = visibleChipCount === null
|
||||
const hasHiddenChips = visibleChipCount !== null && visibleChipCount < chips.length
|
||||
const visibleChips = showAllChips || measuring ? chips : chips.slice(0, visibleChipCount)
|
||||
/**
|
||||
* Only the pre-measurement render needs a height cap; once the chip list is
|
||||
* trimmed the row is three lines tall on its own, and capping it again would
|
||||
* clip whatever shares the last line (the search input is taller than a chip).
|
||||
*/
|
||||
const clamping = measuring && !showAllChips
|
||||
|
||||
/**
|
||||
* One region, four things to say, in this order: a save in flight outranks
|
||||
* everything; a failure outranks the search; and the search outranks a past
|
||||
* success, otherwise "Municípios salvos." would shadow every later result
|
||||
* count for the rest of the session.
|
||||
*/
|
||||
const statusMessage = isPending
|
||||
? 'Salvando municípios…'
|
||||
: feedback?.kind === 'error'
|
||||
? feedback.message
|
||||
: suggesting
|
||||
? resultsMessage(hits.length)
|
||||
: feedback?.kind === 'saved'
|
||||
? 'Municípios salvos.'
|
||||
: ''
|
||||
|
||||
/**
|
||||
* Mounted while measuring so its width is reserved on the last line, but kept
|
||||
* out of the tab order and the accessibility tree until the overflow it
|
||||
* announces is known. On a coarse pointer the cell-wide Drawer trigger sits
|
||||
* above it, so a tap opens the full list instead of expanding in place.
|
||||
*/
|
||||
const expandToggle =
|
||||
measuring || hasHiddenChips ? (
|
||||
<button
|
||||
type="button"
|
||||
data-portfolio-toggle
|
||||
tabIndex={measuring ? -1 : undefined}
|
||||
aria-hidden={measuring || undefined}
|
||||
className={cn(
|
||||
// `min-h-6`: 24px is the SC 2.5.8 target floor for a text button.
|
||||
'inline-flex min-h-6 items-center px-1 text-xs font-medium text-primary underline-offset-4 hover:underline',
|
||||
measuring && 'invisible pointer-events-none',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
const next = !showAllChips
|
||||
setExpanded(next)
|
||||
// Focus is what keeps the cell open — collapsing must release it.
|
||||
if (!next) {
|
||||
setOpen(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showAllChips ? 'Ver menos' : 'Ver mais…'}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
const chipBody = (chip: MunicipalityPortfolioChip) => (
|
||||
<>
|
||||
{chip.kind === 'territory' ? (
|
||||
<MapPinIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
) : null}
|
||||
<span className="truncate">{chip.label}</span>
|
||||
{chip.kind === 'territory' ? (
|
||||
<span className="shrink-0 text-xs tabular-nums opacity-70">
|
||||
{chip.municipalityIds.length}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
const searchHits = useCallback(
|
||||
(query: string, assignedIds: ReadonlySet<number>) =>
|
||||
searchMunicipalityPortfolio(query, searchIndex, assignedIds).map(toRelationHit),
|
||||
[searchIndex],
|
||||
)
|
||||
|
||||
/**
|
||||
* The remove button is a sibling of the chip link (never nested in it) and sits
|
||||
* absolutely on its corner, so revealing it on hover does not resize the chip
|
||||
* or move the row. The `before:` box grows the pointer target to 24×24 (WCAG
|
||||
* 2.2 SC 2.5.8) without growing the 16px circle over a 20px chip. Fine
|
||||
* pointers only: on touch the whole cell opens the Drawer, where the same "×"
|
||||
* is permanently visible at a real tap size.
|
||||
*/
|
||||
const inlineChip = (chip: MunicipalityPortfolioChip) => {
|
||||
const floorReason = removalFloorReason(chip)
|
||||
return (
|
||||
<span
|
||||
key={chip.key}
|
||||
data-portfolio-chip
|
||||
className="group/chip relative inline-flex max-w-full"
|
||||
>
|
||||
{chip.kind === 'municipality' ? (
|
||||
<Badge variant="secondary" className="max-w-full font-normal" asChild>
|
||||
<Link
|
||||
href={`/campanha/municipios/${chip.slug}`}
|
||||
className="truncate underline-offset-4 hover:underline"
|
||||
>
|
||||
{chip.label}
|
||||
</Link>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="max-w-full gap-1 font-normal">
|
||||
{chipBody(chip)}
|
||||
</Badge>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-chip-remove
|
||||
aria-disabled={floorReason !== null || undefined}
|
||||
aria-label={chipRemoveLabel(chip, floorReason)}
|
||||
className={cn(
|
||||
// `border-muted-foreground/70`: the default 12%-alpha `border` token
|
||||
// does not reach 3:1 against a `secondary` chip (SC 1.4.11).
|
||||
'absolute -top-1 -right-1 hidden size-4 place-items-center rounded-full border border-muted-foreground/70 bg-background text-muted-foreground opacity-0 transition-opacity pointer-fine:grid',
|
||||
"before:absolute before:-inset-1 before:content-['']",
|
||||
'group-hover/chip:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
|
||||
floorReason
|
||||
? 'cursor-not-allowed'
|
||||
: 'hover:border-destructive hover:bg-destructive/10 hover:text-destructive hover:opacity-100',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
attemptRemove(chip)
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-2.5" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const suggestionOption = (
|
||||
hit: MunicipalityPortfolioSearchHit,
|
||||
index: number,
|
||||
surface: SuggestionSurface,
|
||||
) => {
|
||||
const capped = additionCapReason(hitMunicipalityIds(hit)) !== null
|
||||
const active = index === activeIndex
|
||||
return (
|
||||
<button
|
||||
key={hit.key}
|
||||
id={optionId(surface, index)}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
aria-disabled={capped || undefined}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2 rounded-md px-2 py-2 text-left text-sm',
|
||||
active && 'bg-muted',
|
||||
capped ? 'opacity-60' : 'hover:bg-muted',
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => {
|
||||
activeFromKeyboard.current = false
|
||||
setActiveIndex(index)
|
||||
}}
|
||||
onClick={() => pickHit(hit)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{hit.label}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{hitDescription(hit)}
|
||||
{capped ? ' · excede o limite' : ''}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The listbox stays mounted while empty (the input's `aria-controls` must
|
||||
* resolve) and the "no results" copy sits OUTSIDE it — inside, a screen
|
||||
* reader would count it as a selectable option.
|
||||
*/
|
||||
const suggestionList = (surface: SuggestionSurface) => (
|
||||
<>
|
||||
<div role="listbox" id={listboxId(surface)} aria-label={SUGGESTIONS_LABEL}>
|
||||
{hits.map((hit, index) => suggestionOption(hit, index, surface))}
|
||||
</div>
|
||||
{hits.length === 0 ? (
|
||||
<p className="px-2 py-2 text-sm text-muted-foreground">Nenhum resultado.</p>
|
||||
) : null}
|
||||
</>
|
||||
const buildFormData = useCallback(
|
||||
(changedIds: number[], assigned: boolean) => {
|
||||
const formData = new FormData()
|
||||
formData.set('ownerId', String(ownerId))
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
for (const id of changedIds) formData.append('municipalityIds', String(id))
|
||||
return formData
|
||||
},
|
||||
[ownerId],
|
||||
)
|
||||
|
||||
const activeOptionId = hits.length > 0 ? optionId(activeSurface, activeIndex) : undefined
|
||||
const copy = useMemo<RelationChipCellCopy>(
|
||||
() => ({
|
||||
searchPlaceholder: 'Buscar município, território ou ZE…',
|
||||
searchLabel: 'Buscar município, território de identidade ou zona eleitoral',
|
||||
suggestionsLabel: 'Sugestões de municípios',
|
||||
emptyDrawerMessage: 'Nenhum município vinculado.',
|
||||
savingMessage: 'Salvando municípios…',
|
||||
savedMessage: 'Municípios salvos.',
|
||||
removedMessage: (count) => `${count} municípios removidos.`,
|
||||
floorMessage: (min) =>
|
||||
`${ownerName} precisa de pelo menos ${min === 1 ? 'um município' : `${min} municípios`}`,
|
||||
capMessage: (max) => `${ownerName} aceita no máximo ${max} municípios.`,
|
||||
}),
|
||||
[ownerName],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
// `aria-busy` + the dimmed row are the only feedback a saved chip gets:
|
||||
// the write is fire-and-forget, and a cell that never reacts reads as
|
||||
// "nothing happened" until the next navigation proves otherwise.
|
||||
aria-busy={isPending || undefined}
|
||||
// Same box in both pointer modes so nothing reflows when the row is touched.
|
||||
className="relative min-w-56 rounded-md border border-transparent p-1 outline-none pointer-fine:hover:bg-muted/40 pointer-fine:focus-within:bg-muted/40"
|
||||
// Reached only on a fine pointer: the coarse overlay below covers the cell
|
||||
// and stops the click, so the pointer policy stays in the media queries.
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('[data-chip-remove]') || target.closest('a')) return
|
||||
inputRef.current?.focus()
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
<Popover open={inlineSuggesting} onOpenChange={setOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
ref={chipRowRef}
|
||||
className={cn(
|
||||
'flex min-h-8 flex-wrap items-center gap-1.5',
|
||||
clamping && 'max-h-18 overflow-hidden',
|
||||
isPending && 'opacity-60 transition-opacity',
|
||||
)}
|
||||
>
|
||||
{chips.length === 0 && !searching ? (
|
||||
<span className="px-1 text-sm text-muted-foreground pointer-fine:hidden">—</span>
|
||||
) : null}
|
||||
{visibleChips.map(inlineChip)}
|
||||
{expandToggle}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
role="combobox"
|
||||
aria-expanded={inlineSuggesting}
|
||||
aria-controls={listboxId('inline')}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={inlineSuggesting ? activeOptionId : undefined}
|
||||
onChange={(event) => {
|
||||
setQuery(event.currentTarget.value)
|
||||
setFeedback(null)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
if (!rootRef.current?.contains(document.activeElement)) {
|
||||
closeSuggestions()
|
||||
}
|
||||
}, 120)
|
||||
}}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder={chips.length ? 'Adicionar…' : SEARCH_PLACEHOLDER}
|
||||
aria-label={SEARCH_LABEL}
|
||||
className="hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block"
|
||||
/>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
{/* Portaled: the table's scroll container would clip an in-flow listbox. */}
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="max-h-64 w-(--radix-popover-trigger-width) overflow-auto p-1"
|
||||
// The input owns focus the whole time — the list is a suggestion
|
||||
// surface, not a dialog, and must never steal or restore focus.
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{suggestionList('inline')}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/*
|
||||
* Coarse pointer: the cell is one big trigger, because a thumb scrolling a
|
||||
* table cannot be asked to tell "open the município" from "delete it". The
|
||||
* links it covers come back inside the Drawer.
|
||||
*
|
||||
* `variant` is hardcoded because the container takes its policy from the
|
||||
* call site (B42) — and here the policy is the `pointer:` media query on
|
||||
* the trigger, not the viewport, so a touch laptop gets this sheet and a
|
||||
* tablet with a mouse gets the inline editor above. `hover:bg-transparent`
|
||||
* undoes the shared class's hover: this trigger is an invisible pane over
|
||||
* the whole cell, and tinting it would tint the chips it covers.
|
||||
*/}
|
||||
<CampaignCellEditOverlay
|
||||
variant="sheet"
|
||||
open={drawerOpen}
|
||||
// The container's trigger lets the click bubble to the cell root, which
|
||||
// opens the inline suggestions — harmless while the sheet is up, but it
|
||||
// would surface a portaled listbox on a touch device the moment the
|
||||
// sheet closed. Clearing the search on close settles both.
|
||||
//
|
||||
// Guarded on the sheet having actually been open: a closed Drawer still
|
||||
// reports `onOpenChange(false)` when a dismiss elsewhere on the page
|
||||
// sweeps the layer stack, and unguarded this erased whatever the fine
|
||||
// pointer had typed into the inline input.
|
||||
onOpenChange={(next) => {
|
||||
setDrawerOpen(next)
|
||||
if (!next && drawerOpen) closeSuggestions()
|
||||
}}
|
||||
title={drawerTitle}
|
||||
description={ownerName}
|
||||
trigger={null}
|
||||
triggerLabel={`Editar municípios de ${ownerName}`}
|
||||
triggerClassName="absolute inset-0 hover:bg-transparent pointer-fine:hidden"
|
||||
triggerBusy={isPending}
|
||||
sheetBodyClassName="gap-3"
|
||||
>
|
||||
{/*
|
||||
* Rendered unmounted, like the repo's other two in-cell sheets: base-ui
|
||||
* keeps the popup mounted through a ~450 ms exit transition, so gating
|
||||
* this on `drawerOpen` animated an empty collapsing box out of view. The
|
||||
* allocation it saved was measured and is not a cost — the closed portal
|
||||
* emits no DOM, and the class string is a tailwind-merge cache hit.
|
||||
*/}
|
||||
<>
|
||||
{chips.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nenhum município vinculado.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{chips.map((chip) => {
|
||||
const floorReason = removalFloorReason(chip)
|
||||
return (
|
||||
<li
|
||||
key={chip.key}
|
||||
className="flex min-h-11 items-center justify-between gap-2 rounded-md border px-2"
|
||||
>
|
||||
{chip.kind === 'municipality' ? (
|
||||
<Link
|
||||
href={`/campanha/municipios/${chip.slug}`}
|
||||
className="flex min-h-11 min-w-0 flex-1 items-center underline-offset-4 hover:underline"
|
||||
>
|
||||
<Badge variant="secondary" className="min-w-0 font-normal">
|
||||
{chipBody(chip)}
|
||||
</Badge>
|
||||
</Link>
|
||||
) : (
|
||||
<Badge variant="secondary" className="min-w-0 gap-1 font-normal">
|
||||
{chipBody(chip)}
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-11 shrink-0', floorReason && 'opacity-50')}
|
||||
aria-disabled={floorReason !== null || undefined}
|
||||
aria-label={chipRemoveLabel(chip, floorReason)}
|
||||
onClick={() => attemptRemove(chip)}
|
||||
>
|
||||
<XIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<Input
|
||||
value={query}
|
||||
role="combobox"
|
||||
aria-expanded={drawerSuggesting}
|
||||
aria-controls={listboxId('drawer')}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={drawerSuggesting ? activeOptionId : undefined}
|
||||
onChange={(event) => {
|
||||
setQuery(event.currentTarget.value)
|
||||
setFeedback(null)
|
||||
}}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder={SEARCH_PLACEHOLDER}
|
||||
aria-label={SEARCH_LABEL}
|
||||
className="min-h-11"
|
||||
/>
|
||||
{searching ? <div className="flex flex-col gap-1">{suggestionList('drawer')}</div> : null}
|
||||
</>
|
||||
</CampaignCellEditOverlay>
|
||||
|
||||
{/*
|
||||
* This cell keeps its own region rather than the shell's `statusMessage`:
|
||||
* the shell mounts that one from the first open of the overlay, and on a
|
||||
* fine pointer this cell's editor IS the cell — the Drawer never opens,
|
||||
* so nothing would ever be announced.
|
||||
*/}
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{statusMessage}
|
||||
</p>
|
||||
</div>
|
||||
<RelationChipCell
|
||||
ownerId={ownerId}
|
||||
ownerName={ownerName}
|
||||
ids={visibleIds}
|
||||
buildChips={buildChips}
|
||||
searchHits={searchHits}
|
||||
minItems={minItems}
|
||||
maxItems={maxItems}
|
||||
draft={draft}
|
||||
onDraftChange={onDraftChange}
|
||||
buildFormData={buildFormData}
|
||||
commitAction={commitAction}
|
||||
drawerTitle={drawerTitle}
|
||||
triggerLabel={`Editar municípios de ${ownerName}`}
|
||||
updateErrorMessage={updateErrorMessage}
|
||||
copy={copy}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
809
src/components/campaign/shared/RelationChipCell.tsx
Normal file
809
src/components/campaign/shared/RelationChipCell.tsx
Normal file
|
|
@ -0,0 +1,809 @@
|
|||
'use client'
|
||||
|
||||
import { XIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover'
|
||||
import { sameIdSet } from '@/lib/sameIdSet'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
const COLLAPSED_CHIP_ROWS = 3
|
||||
/** Matches `gap-1.5` on the chip row, and `max-h-18` (72px) is this math at a 20px chip. */
|
||||
const CHIP_GAP_PX = 6
|
||||
|
||||
const resultsMessage = (count: number): string => {
|
||||
if (count === 0) return 'Nenhum resultado.'
|
||||
return count === 1 ? '1 resultado.' : `${count} resultados.`
|
||||
}
|
||||
|
||||
const withDelta = (
|
||||
base: readonly number[],
|
||||
changedIds: readonly number[],
|
||||
assigned: boolean,
|
||||
): number[] => {
|
||||
const next = new Set(base)
|
||||
for (const id of changedIds) {
|
||||
if (assigned) next.add(id)
|
||||
else next.delete(id)
|
||||
}
|
||||
return [...next]
|
||||
}
|
||||
|
||||
type SuggestionSurface = 'inline' | 'drawer'
|
||||
|
||||
export type RelationChip = {
|
||||
key: string
|
||||
label: string
|
||||
/** Omit for a chip that is not a link (e.g. a batch/territory chip). */
|
||||
href?: string
|
||||
/** Rendered before the label, e.g. a territory pin. */
|
||||
icon?: ReactNode
|
||||
/** Rendered after the label as a small muted count. */
|
||||
trailingLabel?: string
|
||||
/** Ids removing (or re-adding, on undo) this chip touches — usually one. */
|
||||
ids: number[]
|
||||
/** Appended to the label in the remove control's aria-label, e.g. ` — 2 municípios`. */
|
||||
removalSuffix?: string
|
||||
}
|
||||
|
||||
export type RelationSearchHit = {
|
||||
key: string
|
||||
label: string
|
||||
/** Second line under the suggestion. Omit for a flat, single-line list. */
|
||||
description?: string
|
||||
ids: number[]
|
||||
}
|
||||
|
||||
export type RelationChipCellCopy = {
|
||||
searchPlaceholder: string
|
||||
searchLabel: string
|
||||
suggestionsLabel: string
|
||||
emptyDrawerMessage: string
|
||||
savingMessage: string
|
||||
savedMessage: string
|
||||
removedMessage: (count: number) => string
|
||||
/** Only invoked when `minItems` is actually configured. */
|
||||
floorMessage?: (min: number) => string
|
||||
/** Only invoked when `maxItems` is actually configured. */
|
||||
capMessage?: (max: number) => string
|
||||
}
|
||||
|
||||
type RelationChipCellProps = {
|
||||
/** `null` in draft mode (a row that does not exist yet). */
|
||||
ownerId: number | null
|
||||
/** Whose relation this is — spoken in the aria-labels and the Drawer description. */
|
||||
ownerName: string
|
||||
ids: number[]
|
||||
buildChips: (ids: number[]) => RelationChip[]
|
||||
searchHits: (query: string, assignedIds: ReadonlySet<number>) => RelationSearchHit[]
|
||||
/** Floor the relation enforces server-side. */
|
||||
minItems?: number
|
||||
/**
|
||||
* Ceiling the relation enforces server-side. Checked here so a batch that would
|
||||
* blow the cap is refused with the reason BEFORE the optimistic apply — the
|
||||
* server rejection would otherwise revert a whole batch with a message that
|
||||
* never says which limit was hit.
|
||||
*/
|
||||
maxItems?: number
|
||||
/** Draft mode (new row): keep changes local and report them upward. */
|
||||
draft?: boolean
|
||||
onDraftChange?: (ids: number[]) => void
|
||||
buildFormData: (changedIds: number[], assigned: boolean) => FormData
|
||||
commitAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
drawerTitle: string
|
||||
/** Full aria-label for the coarse-pointer, cell-wide Drawer trigger. */
|
||||
triggerLabel: string
|
||||
updateErrorMessage: string
|
||||
copy: RelationChipCellCopy
|
||||
/** Clamp rest chips to 3 rows + "Ver mais…". Default true; pass false when few chips are expected. */
|
||||
measureOverflow?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared interaction machine behind every bounded-set relation edited by chips
|
||||
* (município portfolios, leadership↔dobradinha membership): optimistic delta
|
||||
* commits that survive concurrent toggles, an undo toast scoped to exactly what
|
||||
* changed, an ARIA 1.2 combobox for search/suggestions, a floor/ceiling refused
|
||||
* with a spoken reason before the optimistic apply, and a pointer-fine inline
|
||||
* editor / pointer-coarse Drawer split. What differs between relations — chip
|
||||
* content, search, the write's field names, copy — is entirely prop-driven;
|
||||
* nothing domain-specific lives here.
|
||||
*/
|
||||
export const RelationChipCell = ({
|
||||
ownerId,
|
||||
ownerName,
|
||||
ids,
|
||||
buildChips,
|
||||
searchHits,
|
||||
minItems = 0,
|
||||
maxItems,
|
||||
draft = false,
|
||||
onDraftChange,
|
||||
buildFormData,
|
||||
commitAction,
|
||||
drawerTitle,
|
||||
triggerLabel,
|
||||
updateErrorMessage,
|
||||
copy,
|
||||
measureOverflow = true,
|
||||
}: RelationChipCellProps) => {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [optimistic, setOptimistic] = useState<number[] | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
/** `null` means "render every chip so the next effect can measure them". */
|
||||
const [visibleChipCount, setVisibleChipCount] = useState<number | null>(null)
|
||||
const [measureToken, setMeasureToken] = useState(0)
|
||||
const lastRowWidth = useRef(0)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [feedback, setFeedback] = useState<
|
||||
{ kind: 'saved' } | { kind: 'error'; message: string } | null
|
||||
>(null)
|
||||
/** Active suggestion (ARIA 1.2 combobox): index 0 is pre-selected, so Enter always picks. */
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
/** Only keyboard movement should scroll: the mouse is already where it points. */
|
||||
const activeFromKeyboard = useRef(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const chipRowRef = useRef<HTMLDivElement>(null)
|
||||
const baseId = useId()
|
||||
const listboxId = (surface: SuggestionSurface) => `${baseId}-${surface}-listbox`
|
||||
const optionId = useCallback(
|
||||
(surface: SuggestionSurface, index: number) => `${baseId}-${surface}-option-${index}`,
|
||||
[baseId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setOptimistic((current) => {
|
||||
if (!current) return null
|
||||
return sameIdSet(current, ids) ? null : current
|
||||
})
|
||||
}, [ids])
|
||||
|
||||
const effectiveIds = optimistic ?? ids
|
||||
|
||||
/**
|
||||
* The baseline a deferred delta falls back to, which cannot be the render's
|
||||
* `ids`: the toast's "Desfazer" outlives the render that created it, and by
|
||||
* the time it fires the optimistic value has usually reconciled to null — so
|
||||
* the functional updater takes the fallback branch and would rebuild the row
|
||||
* from a set that predates every edit made since. That divergence is
|
||||
* permanent, because the reconcile effect above only clears on an exact match.
|
||||
*/
|
||||
const latestIds = useRef(ids)
|
||||
useEffect(() => {
|
||||
latestIds.current = effectiveIds
|
||||
}, [effectiveIds])
|
||||
|
||||
const assignedIds = useMemo(() => new Set(effectiveIds), [effectiveIds])
|
||||
const chips = useMemo(() => buildChips(effectiveIds), [effectiveIds, buildChips])
|
||||
|
||||
const searching = query.trim().length > 0
|
||||
/**
|
||||
* Per surface, because Radix can close the popover with text still in the
|
||||
* input — and because the inline list is PORTALED, so `pointer-coarse:hidden`
|
||||
* on the cell would not hide it while the Drawer is up.
|
||||
*/
|
||||
const inlineSuggesting = searching && open && !drawerOpen
|
||||
const drawerSuggesting = searching && drawerOpen
|
||||
const suggesting = inlineSuggesting || drawerSuggesting
|
||||
const hits = useMemo(
|
||||
() => (suggesting ? searchHits(query, assignedIds) : []),
|
||||
[suggesting, query, searchHits, assignedIds],
|
||||
)
|
||||
|
||||
const hitsKey = hits.map((hit) => hit.key).join('|')
|
||||
useEffect(() => {
|
||||
setActiveIndex(0)
|
||||
}, [hitsKey])
|
||||
|
||||
const activeSurface: SuggestionSurface = drawerOpen ? 'drawer' : 'inline'
|
||||
useEffect(() => {
|
||||
if (!activeFromKeyboard.current) return
|
||||
document.getElementById(optionId(activeSurface, activeIndex))?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
})
|
||||
}, [activeIndex, activeSurface, optionId])
|
||||
|
||||
/**
|
||||
* The token is what makes a stale measurement recoverable: an invalidation
|
||||
* batched with a measurement would otherwise settle back on `null` without
|
||||
* changing any dependency, leaving the cell unmeasured forever.
|
||||
*/
|
||||
const invalidateMeasurement = useCallback(() => {
|
||||
setVisibleChipCount(null)
|
||||
setMeasureToken((token) => token + 1)
|
||||
}, [])
|
||||
|
||||
// Keyed by content, not array identity: a re-render that rebuilds the same
|
||||
// chips must not throw away a measurement that is still valid. The remove
|
||||
// button is absolutely positioned, so revealing it never changes a chip's box.
|
||||
const chipsKey = chips.map((chip) => chip.key).join('|')
|
||||
useEffect(() => {
|
||||
if (!measureOverflow) return
|
||||
invalidateMeasurement()
|
||||
}, [chipsKey, measureOverflow, invalidateMeasurement])
|
||||
|
||||
useEffect(() => {
|
||||
if (!measureOverflow) return
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
// Seeded here so the observer's initial callback isn't read as a resize.
|
||||
lastRowWidth.current = row.clientWidth
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (row.clientWidth === lastRowWidth.current) return
|
||||
lastRowWidth.current = row.clientWidth
|
||||
invalidateMeasurement()
|
||||
})
|
||||
observer.observe(row)
|
||||
return () => observer.disconnect()
|
||||
}, [measureOverflow, invalidateMeasurement])
|
||||
|
||||
/**
|
||||
* Runs on the frames where every chip is in the DOM, so the packing of the
|
||||
* three visible rows — and the room the toggle needs on the third one — is
|
||||
* read from the real layout instead of estimated from label lengths.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!measureOverflow) return
|
||||
if (visibleChipCount !== null) return
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
|
||||
const chipElements = [...row.querySelectorAll<HTMLElement>('[data-relation-chip]')]
|
||||
if (chipElements.length === 0) {
|
||||
setVisibleChipCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
const rowRect = row.getBoundingClientRect()
|
||||
const chipRects = chipElements.map((element) => element.getBoundingClientRect())
|
||||
const lastVisibleTop =
|
||||
chipRects[0].top + (chipRects[0].height + CHIP_GAP_PX) * (COLLAPSED_CHIP_ROWS - 1)
|
||||
let fitting = chipRects.filter((rect) => rect.top <= lastVisibleTop + 1).length
|
||||
|
||||
// The toggle — and the search input — must share the last visible line,
|
||||
// otherwise they wrap into a line nobody asked for. The input's box is read
|
||||
// as its `min-width`, not its rect: `flex-1` makes the rendered width the
|
||||
// leftover of the row, which would reserve the whole line. A zero-width
|
||||
// rect means the input is `display: none` (coarse pointer), where the row
|
||||
// has no trailing control to make room for at all.
|
||||
const toggleWidth =
|
||||
row.querySelector<HTMLElement>('[data-relation-toggle]')?.getBoundingClientRect().width ?? 0
|
||||
const inputElement = inputRef.current
|
||||
const inputWidth =
|
||||
inputElement && inputElement.getBoundingClientRect().width > 0
|
||||
? Number.parseFloat(getComputedStyle(inputElement).minWidth) || 0
|
||||
: 0
|
||||
const trailingWidth = toggleWidth + CHIP_GAP_PX + inputWidth
|
||||
while (fitting > 0 && fitting < chipElements.length) {
|
||||
const trailing = chipRects[fitting - 1]
|
||||
if (trailing.right + CHIP_GAP_PX + trailingWidth <= rowRect.right) break
|
||||
fitting -= 1
|
||||
}
|
||||
|
||||
setVisibleChipCount(fitting)
|
||||
}, [measureOverflow, measureToken, visibleChipCount])
|
||||
|
||||
/**
|
||||
* Every delta is applied functionally, forward and back: two chips toggled in
|
||||
* the same burst — or an "Desfazer" pressed after a later add — would otherwise
|
||||
* each compute from the set their own render captured and drop the other's work.
|
||||
*/
|
||||
const commit = (changedIds: number[], assigned: boolean) => {
|
||||
if (changedIds.length === 0) return
|
||||
|
||||
if (draft) {
|
||||
// Advanced synchronously as well: the parent holds the draft in state, so
|
||||
// two toggles batched into one tick would otherwise both read the prop of
|
||||
// the render they were queued from and the second would drop the first.
|
||||
const next = withDelta(latestIds.current, changedIds, assigned)
|
||||
latestIds.current = next
|
||||
onDraftChange?.(next)
|
||||
} else {
|
||||
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, assigned))
|
||||
}
|
||||
setQuery('')
|
||||
setOpen(false)
|
||||
setFeedback(null)
|
||||
|
||||
if (draft) return
|
||||
if (ownerId === null) return
|
||||
|
||||
const formData = buildFormData(changedIds, assigned)
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await commitAction({}, formData)
|
||||
if (result.status === 'success') {
|
||||
setFeedback({ kind: 'saved' })
|
||||
// A batch removal drops several links in one tap, and the row it left
|
||||
// behind carries no trace of what was there. Single removals are one
|
||||
// search away from being restored and get no toast.
|
||||
if (!assigned && changedIds.length > 1) {
|
||||
toast.success(copy.removedMessage(changedIds.length), {
|
||||
action: { label: 'Desfazer', onClick: () => commit(changedIds, true) },
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
// Undo only THIS delta: reverting to the server baseline would also wipe
|
||||
// a sibling toggle from the same burst that already saved.
|
||||
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, !assigned))
|
||||
const message = result.message ?? updateErrorMessage
|
||||
setFeedback({ kind: 'error', message })
|
||||
toast.error(message)
|
||||
})
|
||||
}
|
||||
|
||||
/** The relation's floor, refused here so the server never has to say no. */
|
||||
const removalFloorReason = (chip: RelationChip): string | null =>
|
||||
assignedIds.size - chip.ids.length < minItems
|
||||
? (copy.floorMessage ?? ((min) => `${ownerName} precisa de pelo menos ${min}.`))(minItems)
|
||||
: null
|
||||
|
||||
const additionCapReason = (additions: number[]): string | null =>
|
||||
maxItems !== undefined && assignedIds.size + additions.length > maxItems
|
||||
? (copy.capMessage ?? ((max) => `${ownerName} aceita no máximo ${max}.`))(maxItems)
|
||||
: null
|
||||
|
||||
const chipRemoveLabel = (chip: RelationChip, floorReason: string | null): string =>
|
||||
floorReason
|
||||
? `${floorReason} — não é possível remover ${chip.label}`
|
||||
: `Remover ${chip.label}${chip.removalSuffix ?? ''}`
|
||||
|
||||
/**
|
||||
* Refusals explain themselves on attempt instead of going silent: the control
|
||||
* keeps `aria-disabled` (so it stays in the tab order and can still be
|
||||
* activated) rather than `disabled`, which would hide the only copy of the
|
||||
* reason from every keyboard user.
|
||||
*/
|
||||
const attemptRemove = (chip: RelationChip) => {
|
||||
const floorReason = removalFloorReason(chip)
|
||||
if (floorReason) {
|
||||
toast.error(`${floorReason}.`)
|
||||
return
|
||||
}
|
||||
commit(chip.ids, false)
|
||||
}
|
||||
|
||||
const pickHit = (hit: RelationSearchHit) => {
|
||||
const capReason = additionCapReason(hit.ids)
|
||||
if (capReason) {
|
||||
toast.error(capReason)
|
||||
return
|
||||
}
|
||||
commit(hit.ids, true)
|
||||
}
|
||||
|
||||
const closeSuggestions = () => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
/**
|
||||
* ARIA 1.2 combobox keyboard contract, shared by the inline input and the
|
||||
* Drawer's: Up/Down/Home/End move the active option, Enter picks it, Escape
|
||||
* abandons the search. Without it the suggestions are mouse-only.
|
||||
*/
|
||||
const onSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
if (hits.length === 0) return
|
||||
event.preventDefault()
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||||
activeFromKeyboard.current = true
|
||||
setActiveIndex((current) => (current + delta + hits.length) % hits.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Home' || event.key === 'End') {
|
||||
if (hits.length === 0) return
|
||||
event.preventDefault()
|
||||
activeFromKeyboard.current = true
|
||||
setActiveIndex(event.key === 'Home' ? 0 : hits.length - 1)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
const hit = hits[activeIndex]
|
||||
if (!hit) return
|
||||
event.preventDefault()
|
||||
pickHit(hit)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
if (drawerOpen) {
|
||||
setQuery('')
|
||||
return
|
||||
}
|
||||
closeSuggestions()
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
// Editing needs every chip reachable, so focusing the cell always expands it.
|
||||
const showAllChips = !measureOverflow || expanded || open
|
||||
const measuring = measureOverflow && visibleChipCount === null
|
||||
const hasHiddenChips =
|
||||
measureOverflow && visibleChipCount !== null && visibleChipCount < chips.length
|
||||
const visibleChips =
|
||||
!measureOverflow || showAllChips || measuring || visibleChipCount === null
|
||||
? chips
|
||||
: chips.slice(0, visibleChipCount)
|
||||
/**
|
||||
* Only the pre-measurement render needs a height cap; once the chip list is
|
||||
* trimmed the row is three lines tall on its own, and capping it again would
|
||||
* clip whatever shares the last line (the search input is taller than a chip).
|
||||
*/
|
||||
const clamping = measuring && !showAllChips
|
||||
|
||||
/**
|
||||
* One region, four things to say, in this order: a save in flight outranks
|
||||
* everything; a failure outranks the search; and the search outranks a past
|
||||
* success, otherwise the saved message would shadow every later result count
|
||||
* for the rest of the session.
|
||||
*/
|
||||
const statusMessage = isPending
|
||||
? copy.savingMessage
|
||||
: feedback?.kind === 'error'
|
||||
? feedback.message
|
||||
: suggesting
|
||||
? resultsMessage(hits.length)
|
||||
: feedback?.kind === 'saved'
|
||||
? copy.savedMessage
|
||||
: ''
|
||||
|
||||
/**
|
||||
* Mounted while measuring so its width is reserved on the last line, but kept
|
||||
* out of the tab order and the accessibility tree until the overflow it
|
||||
* announces is known. On a coarse pointer the cell-wide Drawer trigger sits
|
||||
* above it, so a tap opens the full list instead of expanding in place.
|
||||
*/
|
||||
const expandToggle =
|
||||
measureOverflow && (measuring || hasHiddenChips) ? (
|
||||
<button
|
||||
type="button"
|
||||
data-relation-toggle
|
||||
tabIndex={measuring ? -1 : undefined}
|
||||
aria-hidden={measuring || undefined}
|
||||
className={cn(
|
||||
// `min-h-6`: 24px is the SC 2.5.8 target floor for a text button.
|
||||
'inline-flex min-h-6 items-center px-1 text-xs font-medium text-primary underline-offset-4 hover:underline',
|
||||
measuring && 'invisible pointer-events-none',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
const next = !showAllChips
|
||||
setExpanded(next)
|
||||
// Focus is what keeps the cell open — collapsing must release it.
|
||||
if (!next) {
|
||||
setOpen(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showAllChips ? 'Ver menos' : 'Ver mais…'}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
const chipBody = (chip: RelationChip) => (
|
||||
<>
|
||||
{chip.icon}
|
||||
<span className="truncate">{chip.label}</span>
|
||||
{chip.trailingLabel !== undefined ? (
|
||||
<span className="shrink-0 text-xs tabular-nums opacity-70">{chip.trailingLabel}</span>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
/**
|
||||
* The remove button is a sibling of the chip link (never nested in it) and sits
|
||||
* absolutely on its corner, so revealing it on hover does not resize the chip
|
||||
* or move the row. The `before:` box grows the pointer target to 24×24 (WCAG
|
||||
* 2.2 SC 2.5.8) without growing the 16px circle over a 20px chip. Fine
|
||||
* pointers only: on touch the whole cell opens the Drawer, where the same "×"
|
||||
* is permanently visible at a real tap size.
|
||||
*/
|
||||
const inlineChip = (chip: RelationChip) => {
|
||||
const floorReason = removalFloorReason(chip)
|
||||
return (
|
||||
<span
|
||||
key={chip.key}
|
||||
data-relation-chip
|
||||
className="group/chip relative inline-flex max-w-full"
|
||||
>
|
||||
{chip.href ? (
|
||||
<Badge variant="secondary" className="max-w-full font-normal" asChild>
|
||||
<Link href={chip.href} className="truncate underline-offset-4 hover:underline">
|
||||
{chipBody(chip)}
|
||||
</Link>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="max-w-full gap-1 font-normal">
|
||||
{chipBody(chip)}
|
||||
</Badge>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-chip-remove
|
||||
aria-disabled={floorReason !== null || undefined}
|
||||
aria-label={chipRemoveLabel(chip, floorReason)}
|
||||
className={cn(
|
||||
// `border-muted-foreground/70`: the default 12%-alpha `border` token
|
||||
// does not reach 3:1 against a `secondary` chip (SC 1.4.11).
|
||||
'absolute -top-1 -right-1 hidden size-4 place-items-center rounded-full border border-muted-foreground/70 bg-background text-muted-foreground opacity-0 transition-opacity pointer-fine:grid',
|
||||
"before:absolute before:-inset-1 before:content-['']",
|
||||
'group-hover/chip:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
|
||||
floorReason
|
||||
? 'cursor-not-allowed'
|
||||
: 'hover:border-destructive hover:bg-destructive/10 hover:text-destructive hover:opacity-100',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
attemptRemove(chip)
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-2.5" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const suggestionOption = (hit: RelationSearchHit, index: number, surface: SuggestionSurface) => {
|
||||
const capped = additionCapReason(hit.ids) !== null
|
||||
const active = index === activeIndex
|
||||
return (
|
||||
<button
|
||||
key={hit.key}
|
||||
id={optionId(surface, index)}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
aria-disabled={capped || undefined}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2 rounded-md px-2 py-2 text-left text-sm',
|
||||
active && 'bg-muted',
|
||||
capped ? 'opacity-60' : 'hover:bg-muted',
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => {
|
||||
activeFromKeyboard.current = false
|
||||
setActiveIndex(index)
|
||||
}}
|
||||
onClick={() => pickHit(hit)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{hit.label}</span>
|
||||
{hit.description ? (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{hit.description}
|
||||
{capped ? ' · excede o limite' : ''}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The listbox stays mounted while empty (the input's `aria-controls` must
|
||||
* resolve) and the "no results" copy sits OUTSIDE it — inside, a screen
|
||||
* reader would count it as a selectable option.
|
||||
*/
|
||||
const suggestionList = (surface: SuggestionSurface) => (
|
||||
<>
|
||||
<div role="listbox" id={listboxId(surface)} aria-label={copy.suggestionsLabel}>
|
||||
{hits.map((hit, index) => suggestionOption(hit, index, surface))}
|
||||
</div>
|
||||
{hits.length === 0 ? (
|
||||
<p className="px-2 py-2 text-sm text-muted-foreground">Nenhum resultado.</p>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
const activeOptionId = hits.length > 0 ? optionId(activeSurface, activeIndex) : undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
// `aria-busy` + the dimmed row are the only feedback a saved chip gets:
|
||||
// the write is fire-and-forget, and a cell that never reacts reads as
|
||||
// "nothing happened" until the next navigation proves otherwise.
|
||||
aria-busy={isPending || undefined}
|
||||
// Same box in both pointer modes so nothing reflows when the row is touched.
|
||||
className="relative min-w-56 rounded-md border border-transparent p-1 outline-none pointer-fine:hover:bg-muted/40 pointer-fine:focus-within:bg-muted/40"
|
||||
// Reached only on a fine pointer: the coarse overlay below covers the cell
|
||||
// and stops the click, so the pointer policy stays in the media queries.
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('[data-chip-remove]') || target.closest('a')) return
|
||||
inputRef.current?.focus()
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
<Popover open={inlineSuggesting} onOpenChange={setOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
ref={chipRowRef}
|
||||
className={cn(
|
||||
'flex min-h-8 flex-wrap items-center gap-1.5',
|
||||
clamping && 'max-h-18 overflow-hidden',
|
||||
isPending && 'opacity-60 transition-opacity',
|
||||
)}
|
||||
>
|
||||
{chips.length === 0 && !searching ? (
|
||||
<span className="px-1 text-sm text-muted-foreground pointer-fine:hidden">—</span>
|
||||
) : null}
|
||||
{visibleChips.map(inlineChip)}
|
||||
{expandToggle}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
role="combobox"
|
||||
aria-expanded={inlineSuggesting}
|
||||
aria-controls={listboxId('inline')}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={inlineSuggesting ? activeOptionId : undefined}
|
||||
onChange={(event) => {
|
||||
setQuery(event.currentTarget.value)
|
||||
setFeedback(null)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
if (!rootRef.current?.contains(document.activeElement)) {
|
||||
closeSuggestions()
|
||||
}
|
||||
}, 120)
|
||||
}}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder={chips.length ? 'Adicionar…' : copy.searchPlaceholder}
|
||||
aria-label={copy.searchLabel}
|
||||
className="hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block"
|
||||
/>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
{/* Portaled: the table's scroll container would clip an in-flow listbox. */}
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="max-h-64 w-(--radix-popover-trigger-width) overflow-auto p-1"
|
||||
// The input owns focus the whole time — the list is a suggestion
|
||||
// surface, not a dialog, and must never steal or restore focus.
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{suggestionList('inline')}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/*
|
||||
* Coarse pointer: the cell is one big trigger, because a thumb scrolling a
|
||||
* table cannot be asked to tell "open the entity" from "delete it". The
|
||||
* links it covers come back inside the Drawer.
|
||||
*
|
||||
* `variant` is hardcoded because the container takes its policy from the
|
||||
* call site (B42) — and here the policy is the `pointer:` media query on
|
||||
* the trigger, not the viewport, so a touch laptop gets this sheet and a
|
||||
* tablet with a mouse gets the inline editor above. `hover:bg-transparent`
|
||||
* undoes the shared class's hover: this trigger is an invisible pane over
|
||||
* the whole cell, and tinting it would tint the chips it covers.
|
||||
*/}
|
||||
<CampaignCellEditOverlay
|
||||
variant="sheet"
|
||||
open={drawerOpen}
|
||||
// The container's trigger lets the click bubble to the cell root, which
|
||||
// opens the inline suggestions — harmless while the sheet is up, but it
|
||||
// would surface a portaled listbox on a touch device the moment the
|
||||
// sheet closed. Clearing the search on close settles both.
|
||||
//
|
||||
// Guarded on the sheet having actually been open: a closed Drawer still
|
||||
// reports `onOpenChange(false)` when a dismiss elsewhere on the page
|
||||
// sweeps the layer stack, and unguarded this erased whatever the fine
|
||||
// pointer had typed into the inline input.
|
||||
onOpenChange={(next) => {
|
||||
setDrawerOpen(next)
|
||||
if (!next && drawerOpen) closeSuggestions()
|
||||
}}
|
||||
title={drawerTitle}
|
||||
description={ownerName}
|
||||
trigger={null}
|
||||
triggerLabel={triggerLabel}
|
||||
triggerClassName="absolute inset-0 hover:bg-transparent pointer-fine:hidden"
|
||||
triggerBusy={isPending}
|
||||
sheetBodyClassName="gap-3"
|
||||
>
|
||||
{/*
|
||||
* Rendered unmounted, like the repo's other in-cell sheets: base-ui
|
||||
* keeps the popup mounted through a ~450 ms exit transition, so gating
|
||||
* this on `drawerOpen` animated an empty collapsing box out of view. The
|
||||
* allocation it saved was measured and is not a cost — the closed portal
|
||||
* emits no DOM, and the class string is a tailwind-merge cache hit.
|
||||
*/}
|
||||
<>
|
||||
{chips.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{copy.emptyDrawerMessage}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{chips.map((chip) => {
|
||||
const floorReason = removalFloorReason(chip)
|
||||
return (
|
||||
<li
|
||||
key={chip.key}
|
||||
className="flex min-h-11 items-center justify-between gap-2 rounded-md border px-2"
|
||||
>
|
||||
{chip.href ? (
|
||||
<Link
|
||||
href={chip.href}
|
||||
className="flex min-h-11 min-w-0 flex-1 items-center underline-offset-4 hover:underline"
|
||||
>
|
||||
<Badge variant="secondary" className="min-w-0 font-normal">
|
||||
{chipBody(chip)}
|
||||
</Badge>
|
||||
</Link>
|
||||
) : (
|
||||
<Badge variant="secondary" className="min-w-0 gap-1 font-normal">
|
||||
{chipBody(chip)}
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-11 shrink-0', floorReason && 'opacity-50')}
|
||||
aria-disabled={floorReason !== null || undefined}
|
||||
aria-label={chipRemoveLabel(chip, floorReason)}
|
||||
onClick={() => attemptRemove(chip)}
|
||||
>
|
||||
<XIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<Input
|
||||
value={query}
|
||||
role="combobox"
|
||||
aria-expanded={drawerSuggesting}
|
||||
aria-controls={listboxId('drawer')}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={drawerSuggesting ? activeOptionId : undefined}
|
||||
onChange={(event) => {
|
||||
setQuery(event.currentTarget.value)
|
||||
setFeedback(null)
|
||||
}}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder={copy.searchPlaceholder}
|
||||
aria-label={copy.searchLabel}
|
||||
className="min-h-11"
|
||||
/>
|
||||
{searching ? <div className="flex flex-col gap-1">{suggestionList('drawer')}</div> : null}
|
||||
</>
|
||||
</CampaignCellEditOverlay>
|
||||
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{statusMessage}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,28 +1,22 @@
|
|||
import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership'
|
||||
import { nextIdsAfterMembership } from '@/lib/relationMembershipDelta'
|
||||
import {
|
||||
LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
MAX_LEADERSHIP_STATE_DEPUTIES,
|
||||
} from '@/lib/schemas/leadership'
|
||||
|
||||
/**
|
||||
* Returns the next state-deputy-id list after applying one membership
|
||||
* change, or `null` when the leadership is already in the desired state (no
|
||||
* write needed). Mirrors `nextAdvisorIdsAfterMembership` — the pure delta
|
||||
* calculator for the other side of a relation edited by chip+combobox
|
||||
* (B31's `leadership.stateDeputies`, edited from the "Dobradinhas" column of
|
||||
* `/campanha/liderancas`).
|
||||
* write needed) — the `leadership.stateDeputies` side of the relation, edited
|
||||
* from the "Dobradinhas" column of `/campanha/liderancas` (B31) and from the
|
||||
* "Lideranças" column of `/campanha/dobradinhas` (B36).
|
||||
*/
|
||||
export const nextStateDeputyIdsAfterMembership = (
|
||||
currentStateDeputyIDs: readonly number[],
|
||||
stateDeputyId: number,
|
||||
assigned: boolean,
|
||||
): number[] | null => {
|
||||
const alreadyAssigned = currentStateDeputyIDs.includes(stateDeputyId)
|
||||
if (assigned === alreadyAssigned) return null
|
||||
|
||||
if (assigned) {
|
||||
if (currentStateDeputyIDs.length >= MAX_LEADERSHIP_STATE_DEPUTIES) {
|
||||
throw new Error(
|
||||
`Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`,
|
||||
)
|
||||
}
|
||||
return [...currentStateDeputyIDs, stateDeputyId]
|
||||
}
|
||||
return currentStateDeputyIDs.filter((id) => id !== stateDeputyId)
|
||||
}
|
||||
): number[] | null =>
|
||||
nextIdsAfterMembership(currentStateDeputyIDs, stateDeputyId, assigned, {
|
||||
max: MAX_LEADERSHIP_STATE_DEPUTIES,
|
||||
message: LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { MAX_ADVISORS_PER_MUNICIPALITY } from '@/lib/schemas/municipality'
|
||||
import { nextIdsAfterMembership } from '@/lib/relationMembershipDelta'
|
||||
import {
|
||||
MAX_ADVISORS_PER_MUNICIPALITY,
|
||||
MUNICIPALITY_ADVISORS_CAP_MESSAGE,
|
||||
} from '@/lib/schemas/municipality'
|
||||
|
||||
/**
|
||||
* Returns the next advisor-id list after applying one membership change, or
|
||||
|
|
@ -11,17 +15,8 @@ export const nextAdvisorIdsAfterMembership = (
|
|||
currentAdvisorIDs: readonly number[],
|
||||
advisorId: number,
|
||||
assigned: boolean,
|
||||
): number[] | null => {
|
||||
const alreadyAssigned = currentAdvisorIDs.includes(advisorId)
|
||||
if (assigned === alreadyAssigned) return null
|
||||
|
||||
if (assigned) {
|
||||
if (currentAdvisorIDs.length >= MAX_ADVISORS_PER_MUNICIPALITY) {
|
||||
throw new Error(
|
||||
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
|
||||
)
|
||||
}
|
||||
return [...currentAdvisorIDs, advisorId]
|
||||
}
|
||||
return currentAdvisorIDs.filter((id) => id !== advisorId)
|
||||
}
|
||||
): number[] | null =>
|
||||
nextIdsAfterMembership(currentAdvisorIDs, advisorId, assigned, {
|
||||
max: MAX_ADVISORS_PER_MUNICIPALITY,
|
||||
message: MUNICIPALITY_ADVISORS_CAP_MESSAGE,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -125,6 +125,38 @@ const portfolioIndexDerivations = (
|
|||
return derived
|
||||
}
|
||||
|
||||
/**
|
||||
* The index an advisor's suggestions may draw from, as ONE array shared by every
|
||||
* row — a per-row `.filter()` returns a distinct array identity even when the
|
||||
* content is identical, which silently defeats the `WeakMap` above and makes each
|
||||
* searching row rebuild the same O(435) derivation. Keyed on both inputs because
|
||||
* the scope belongs to the actor, not to the index.
|
||||
*/
|
||||
const scopedIndexByAddableIds = new WeakMap<
|
||||
ReadonlySet<number>,
|
||||
WeakMap<readonly MunicipalityPortfolioIndexEntry[], readonly MunicipalityPortfolioIndexEntry[]>
|
||||
>()
|
||||
|
||||
export const scopedPortfolioIndex = (
|
||||
index: readonly MunicipalityPortfolioIndexEntry[],
|
||||
addableIds: ReadonlySet<number> | undefined,
|
||||
): readonly MunicipalityPortfolioIndexEntry[] => {
|
||||
if (!addableIds) return index
|
||||
|
||||
let byIndex = scopedIndexByAddableIds.get(addableIds)
|
||||
if (!byIndex) {
|
||||
byIndex = new WeakMap()
|
||||
scopedIndexByAddableIds.set(addableIds, byIndex)
|
||||
}
|
||||
|
||||
const cached = byIndex.get(index)
|
||||
if (cached) return cached
|
||||
|
||||
const scoped = index.filter((entry) => addableIds.has(entry.id))
|
||||
byIndex.set(index, scoped)
|
||||
return scoped
|
||||
}
|
||||
|
||||
const municipalityIdsForTseZone = (
|
||||
zoneNumber: number,
|
||||
bySlug: Map<string, MunicipalityPortfolioIndexEntry>,
|
||||
|
|
|
|||
22
src/lib/municipalityStateDeputyMembership.ts
Normal file
22
src/lib/municipalityStateDeputyMembership.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { nextIdsAfterMembership } from '@/lib/relationMembershipDelta'
|
||||
import {
|
||||
MAX_STATE_DEPUTIES_PER_MUNICIPALITY,
|
||||
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
} from '@/lib/schemas/municipality'
|
||||
|
||||
/**
|
||||
* Returns the next state-deputy-id list for a município after applying one
|
||||
* membership change, or `null` when the município is already in the desired
|
||||
* state (no write needed) — the município side of the relation that a
|
||||
* `stateDeputy` chip batch touches (B37, from the "Municípios" column of
|
||||
* `/campanha/dobradinhas`).
|
||||
*/
|
||||
export const nextStateDeputyIdsAfterMunicipalityMembership = (
|
||||
currentStateDeputyIDs: readonly number[],
|
||||
stateDeputyId: number,
|
||||
assigned: boolean,
|
||||
): number[] | null =>
|
||||
nextIdsAfterMembership(currentStateDeputyIDs, stateDeputyId, assigned, {
|
||||
max: MAX_STATE_DEPUTIES_PER_MUNICIPALITY,
|
||||
message: MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
})
|
||||
32
src/lib/relationMembershipDelta.ts
Normal file
32
src/lib/relationMembershipDelta.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Returns the next id list after applying ONE membership change to a bounded
|
||||
* relation, or `null` when the owner is already in the desired state (no write
|
||||
* needed) — the shape every chip-toggled relation in `/campanha` needs before
|
||||
* it writes: `municipality.advisors` (B19/B27), `leadership.stateDeputies`
|
||||
* (B31/B36) and `municipality.stateDeputies` (B37).
|
||||
*
|
||||
* The cap travels as data because that is the only thing that differs between
|
||||
* them, and its `message` is a constant owned by the relation's schema rather
|
||||
* than a literal: `mapCampaignFormActionError` matches thrown messages against
|
||||
* `safeMessages` by exact string, so a reworded throw would silently collapse
|
||||
* a real refusal into the generic "tente novamente".
|
||||
*
|
||||
* Not the batch sibling: `nextMunicipalityIdsAfterLeadershipMembership` moves a
|
||||
* whole território at once and enforces a floor, so it reports `added`/`changed`
|
||||
* instead of just the next list.
|
||||
*/
|
||||
export const nextIdsAfterMembership = (
|
||||
currentIDs: readonly number[],
|
||||
id: number,
|
||||
assigned: boolean,
|
||||
cap: { max: number; message: string },
|
||||
): number[] | null => {
|
||||
const alreadyAssigned = currentIDs.includes(id)
|
||||
if (assigned === alreadyAssigned) return null
|
||||
|
||||
if (assigned) {
|
||||
if (currentIDs.length >= cap.max) throw new Error(cap.message)
|
||||
return [...currentIDs, id]
|
||||
}
|
||||
return currentIDs.filter((currentId) => currentId !== id)
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { MAX_ADVISORS_PER_MUNICIPALITY } from '@/lib/schemas/municipality'
|
||||
import {
|
||||
MAX_MUNICIPALITIES_PER_BATCH,
|
||||
MUNICIPALITY_ADVISORS_CAP_MESSAGE,
|
||||
} from '@/lib/schemas/municipality'
|
||||
import {
|
||||
brazilianMobile,
|
||||
optionalPersistedEmail,
|
||||
|
|
@ -41,7 +44,7 @@ export const ADVISOR_ACTION_SAFE_MESSAGES = [
|
|||
PLACEHOLDER_RESET_MESSAGE,
|
||||
'Só é possível gerenciar contas com papel de Assessor nesta tela.',
|
||||
'Use Meu perfil para alterar a própria conta.',
|
||||
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
|
||||
MUNICIPALITY_ADVISORS_CAP_MESSAGE,
|
||||
] as const
|
||||
|
||||
export const advisorCreateSchema = z.object({
|
||||
|
|
@ -64,7 +67,7 @@ export const advisorProfileUpdateSchema = z
|
|||
|
||||
export const advisorMunicipalitiesBatchSchema = z.object({
|
||||
advisorId: positiveRelationshipId,
|
||||
municipalityIds: z.array(positiveRelationshipId).min(1).max(435),
|
||||
municipalityIds: z.array(positiveRelationshipId).min(1).max(MAX_MUNICIPALITIES_PER_BATCH),
|
||||
assigned: z.boolean(),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ export const LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE =
|
|||
|
||||
export const LEADERSHIP_MUNICIPALITY_CAP_MESSAGE = `Cada liderança aceita no máximo ${MAX_LEADERSHIP_MUNICIPALITIES} municípios.`
|
||||
|
||||
/** Thrown by the chip toggle on both sides of the relation (B31/B36), so both routes allowlist it. */
|
||||
export const LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE = `Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`
|
||||
|
||||
/** Thrown by the actions and matched verbatim by every route's `safeMessages`. */
|
||||
export const LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE =
|
||||
'Você só pode vincular lideranças aos municípios que assessora.'
|
||||
|
|
|
|||
|
|
@ -7,9 +7,29 @@ import { voteEstimateScenarioFieldsSchema } from '@/lib/schemas/votePledge'
|
|||
export const politicalTrendStatuses = ['favoravel', 'neutra', 'desfavoravel'] as const
|
||||
export type PoliticalTrendStatusValue = (typeof politicalTrendStatuses)[number]
|
||||
|
||||
/**
|
||||
* Anti-payload bound, not a domain rule: `municipality.stateDeputies` has no
|
||||
* product-level ceiling (a município can be dobrado with any number of
|
||||
* deputados), and the catalog itself caps out at 435. Named so the chip-batch
|
||||
* write (B37) and this whole-array strategy update stay on the same number —
|
||||
* they parse through the same schema, and a batch write that pushed past a
|
||||
* lower bound would leave the strategy ficha unsavable on its next submit.
|
||||
*/
|
||||
export const MAX_STATE_DEPUTIES_PER_MUNICIPALITY = 435
|
||||
|
||||
/**
|
||||
* Anti-payload bound on a batch that names many municípios at once (a whole
|
||||
* território/ZE): the catalog is 435 rows and ids are unique, so this is the
|
||||
* whole universe and not a product rule. Named because both município-side
|
||||
* batch schemas (`advisor`, `stateDeputy`) were carrying a bare `435`.
|
||||
*/
|
||||
export const MAX_MUNICIPALITIES_PER_BATCH = 435
|
||||
|
||||
export const MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE = `Cada município aceita no máximo ${MAX_STATE_DEPUTIES_PER_MUNICIPALITY} dobradinhas.`
|
||||
|
||||
const stateDeputiesArraySchema = z
|
||||
.array(positiveRelationshipId)
|
||||
.max(20)
|
||||
.max(MAX_STATE_DEPUTIES_PER_MUNICIPALITY)
|
||||
.transform((ids) => [...new Set(ids)])
|
||||
|
||||
export const municipalityStrategyUpdateSchema = z.object({
|
||||
|
|
@ -40,6 +60,8 @@ export const parsePoliticalTrendStatusFormValue = (
|
|||
|
||||
export const MAX_ADVISORS_PER_MUNICIPALITY = 10
|
||||
|
||||
export const MUNICIPALITY_ADVISORS_CAP_MESSAGE = `Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`
|
||||
|
||||
export const municipalityAdvisorsAssignmentSchema = z.object({
|
||||
municipality: positiveRelationshipId,
|
||||
advisors: z
|
||||
|
|
@ -60,7 +82,7 @@ export const MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE =
|
|||
|
||||
export const MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES = [
|
||||
MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE,
|
||||
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
|
||||
MUNICIPALITY_ADVISORS_CAP_MESSAGE,
|
||||
] as const
|
||||
|
||||
export const MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE =
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
MAX_MUNICIPALITIES_PER_BATCH,
|
||||
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
} from '@/lib/schemas/municipality'
|
||||
import {
|
||||
positiveRelationshipId,
|
||||
trimmedNullableText,
|
||||
|
|
@ -18,5 +22,43 @@ export const stateDeputyUpdateSchema = z.object({
|
|||
notes: trimmedNullableText(4000),
|
||||
})
|
||||
|
||||
/**
|
||||
* Refused staff-only write on any dobradinha — thrown by the create/update
|
||||
* policy AND by the município batch. Named once because every route's
|
||||
* `safeMessages` matches it by exact string: a reworded literal in one of the
|
||||
* four places it used to live would have silently become the generic error.
|
||||
*/
|
||||
export const STATE_DEPUTY_STAFF_MESSAGE =
|
||||
'Somente a coordenação e a assessoria gerenciam dobradinhas.'
|
||||
|
||||
/** Same exact-string contract as above, shared by the policy and the create route. */
|
||||
export const STATE_DEPUTY_CONFLICT_MESSAGE = 'Já existe uma dobradinha com este nome.'
|
||||
|
||||
/**
|
||||
* Delta write for the "Municípios" column of `/campanha/dobradinhas` (B37) —
|
||||
* adds or removes a set of municipalities (one chip, or a whole território/ZE)
|
||||
* on `municipality.stateDeputies` — the inverted side of the relation this
|
||||
* `stateDeputy` sits on. The bound is the universe of municípios in one batch
|
||||
* call (a whole território), not the per-município cap on dobradinhas, which
|
||||
* lives in `MAX_STATE_DEPUTIES_PER_MUNICIPALITY`.
|
||||
*/
|
||||
export const stateDeputyMunicipalitiesBatchSchema = z.object({
|
||||
stateDeputyId: positiveRelationshipId,
|
||||
municipalityIds: z
|
||||
.array(positiveRelationshipId)
|
||||
.min(1)
|
||||
.max(MAX_MUNICIPALITIES_PER_BATCH)
|
||||
.transform((ids) => [...new Set(ids)]),
|
||||
assigned: z.boolean(),
|
||||
})
|
||||
|
||||
export const STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES = [
|
||||
STATE_DEPUTY_STAFF_MESSAGE,
|
||||
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
] as const
|
||||
|
||||
export type StateDeputyCreateInput = z.input<typeof stateDeputyCreateSchema>
|
||||
export type StateDeputyUpdateInput = z.input<typeof stateDeputyUpdateSchema>
|
||||
export type StateDeputyMunicipalitiesBatchInput = z.input<
|
||||
typeof stateDeputyMunicipalitiesBatchSchema
|
||||
>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export type StateDeputyRowViewModel = {
|
|||
name: string
|
||||
slug: string
|
||||
party: string | null
|
||||
municipalityCount: number
|
||||
municipalityIDs: number[]
|
||||
leaderships: LeadershipRelationSummary[]
|
||||
}
|
||||
|
||||
|
|
@ -98,11 +98,14 @@ export const loadStateDeputyListPageData = async (
|
|||
])
|
||||
|
||||
const stateDeputyIDs = result.docs.map((doc) => doc.id)
|
||||
const municipalityCounts = new Map<number, number>()
|
||||
const municipalityIDsByDeputy = new Map<number, number[]>()
|
||||
const leadershipsByDeputy = new Map<number, LeadershipRelationSummary[]>()
|
||||
|
||||
if (stateDeputyIDs.length) {
|
||||
const [municipalities, leaderships] = await Promise.all([
|
||||
// Intentional admin bypass: the chips render outside an advisor's scope
|
||||
// on purpose (B34 precedent) — an advisor must be able to see and undo
|
||||
// a município link even outside their own portfolio.
|
||||
payload.find({
|
||||
collection: 'municipality',
|
||||
where: { stateDeputies: { in: stateDeputyIDs } },
|
||||
|
|
@ -130,7 +133,9 @@ export const loadStateDeputyListPageData = async (
|
|||
for (const deputy of municipality.stateDeputies ?? []) {
|
||||
const id = relationshipId(deputy)
|
||||
if (id !== null && stateDeputyIDs.includes(id)) {
|
||||
municipalityCounts.set(id, (municipalityCounts.get(id) ?? 0) + 1)
|
||||
const list = municipalityIDsByDeputy.get(id) ?? []
|
||||
list.push(municipality.id)
|
||||
municipalityIDsByDeputy.set(id, list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -162,7 +167,7 @@ export const loadStateDeputyListPageData = async (
|
|||
name: doc.name,
|
||||
slug: doc.slug,
|
||||
party: doc.party ?? null,
|
||||
municipalityCount: municipalityCounts.get(doc.id) ?? 0,
|
||||
municipalityIDs: municipalityIDsByDeputy.get(doc.id) ?? [],
|
||||
leaderships: leadershipsByDeputy.get(doc.id) ?? [],
|
||||
})),
|
||||
totalDocs: result.totalDocs,
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ describe('loadStateDeputyListPageData', () => {
|
|||
expect(result.rows[0]).toMatchObject({
|
||||
slug: stateDeputy.slug,
|
||||
party: 'PT',
|
||||
municipalityCount: 0,
|
||||
municipalityIDs: [],
|
||||
leaderships: [],
|
||||
})
|
||||
})
|
||||
|
|
|
|||
185
tests/int/campaignStateDeputyMunicipalityMembership.int.spec.ts
Normal file
185
tests/int/campaignStateDeputyMunicipalityMembership.int.spec.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { setStateDeputyMunicipalitiesBatchRecord } from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import config from '@/payload.config'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const stateDeputyIdsOf = async (municipalityID: number): Promise<number[]> => {
|
||||
const municipality = await payload.findByID({
|
||||
collection: 'municipality',
|
||||
id: municipalityID,
|
||||
depth: 0,
|
||||
select: { stateDeputies: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
return (municipality.stateDeputies ?? []).map((entry) =>
|
||||
typeof entry === 'number' ? entry : entry.id,
|
||||
)
|
||||
}
|
||||
|
||||
describe('setStateDeputyMunicipalitiesBatchRecord (B37)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it.each(['coordinator', 'candidate'] as const)(
|
||||
'lets a %s add and remove a municipality by delta',
|
||||
async (role) => {
|
||||
const fixtures = campaignFixtures()
|
||||
const actor = await fixtures.createCampaignUser(role)
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
const { slugs: addedSlugs } = await setStateDeputyMunicipalitiesBatchRecord(payload, actor, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [municipality.id],
|
||||
assigned: true,
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
expect(addedSlugs).toEqual([municipality.slug])
|
||||
expect(await stateDeputyIdsOf(municipality.id)).toEqual([stateDeputy.id])
|
||||
|
||||
const { slugs: removedSlugs } = await setStateDeputyMunicipalitiesBatchRecord(
|
||||
payload,
|
||||
actor,
|
||||
{
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [municipality.id],
|
||||
assigned: false,
|
||||
},
|
||||
)
|
||||
expect(removedSlugs).toEqual([municipality.slug])
|
||||
expect(await stateDeputyIdsOf(municipality.id)).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
it('lets an advisor add a state deputy to a municipality they administer', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const administered = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered, [advisor])
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
const { slugs } = await setStateDeputyMunicipalitiesBatchRecord(payload, advisor, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [administered.id],
|
||||
assigned: true,
|
||||
})
|
||||
|
||||
expect(slugs).toEqual([administered.slug])
|
||||
expect(await stateDeputyIdsOf(administered.id)).toEqual([stateDeputy.id])
|
||||
})
|
||||
|
||||
it('denies an advisor writing to a municipality outside their portfolio', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const outside = await fixtures.getMunicipality()
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
await expect(
|
||||
setStateDeputyMunicipalitiesBatchRecord(payload, advisor, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [outside.id],
|
||||
assigned: true,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(await stateDeputyIdsOf(outside.id)).toEqual([])
|
||||
})
|
||||
|
||||
it('denies a leader actor', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const leaderActor = await fixtures.createCampaignUser('leader')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
await expect(
|
||||
setStateDeputyMunicipalitiesBatchRecord(payload, leaderActor, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [municipality.id],
|
||||
assigned: true,
|
||||
}),
|
||||
).rejects.toThrow(/coordenação e a assessoria/i)
|
||||
})
|
||||
|
||||
it('adds a whole batch (território / zona) in one write, touching every município', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const first = await fixtures.getMunicipality()
|
||||
const second = await fixtures.getMunicipality()
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
const { slugs } = await setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [first.id, second.id],
|
||||
assigned: true,
|
||||
})
|
||||
fixtures.touchMunicipality(first.id)
|
||||
fixtures.touchMunicipality(second.id)
|
||||
|
||||
expect(slugs.sort()).toEqual([first.slug, second.slug].sort())
|
||||
expect(await stateDeputyIdsOf(first.id)).toEqual([stateDeputy.id])
|
||||
expect(await stateDeputyIdsOf(second.id)).toEqual([stateDeputy.id])
|
||||
})
|
||||
|
||||
it('is idempotent — a no-op write touches nothing and returns no slugs to revalidate', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
await setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [municipality.id],
|
||||
assigned: true,
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
const { slugs } = await setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [municipality.id],
|
||||
assigned: true,
|
||||
})
|
||||
expect(slugs).toEqual([])
|
||||
expect(await stateDeputyIdsOf(municipality.id)).toEqual([stateDeputy.id])
|
||||
})
|
||||
|
||||
it('serializes a concurrent delta on the same municipality through the advisory lock', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const first = await fixtures.createStateDeputy()
|
||||
const second = await fixtures.createStateDeputy()
|
||||
|
||||
await Promise.all([
|
||||
setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
|
||||
stateDeputyId: first.id,
|
||||
municipalityIds: [municipality.id],
|
||||
assigned: true,
|
||||
}),
|
||||
setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
|
||||
stateDeputyId: second.id,
|
||||
municipalityIds: [municipality.id],
|
||||
assigned: true,
|
||||
}),
|
||||
])
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
expect((await stateDeputyIdsOf(municipality.id)).sort((a, b) => a - b)).toEqual(
|
||||
[first.id, second.id].sort((a, b) => a - b),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { nextStateDeputyIdsAfterMembership } from '@/lib/leadershipStateDeputyMembership'
|
||||
import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership'
|
||||
|
||||
describe('nextStateDeputyIdsAfterMembership', () => {
|
||||
it('adds a state deputy not yet present', () => {
|
||||
expect(nextStateDeputyIdsAfterMembership([1, 2], 3, true)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('removes a state deputy present in the list', () => {
|
||||
expect(nextStateDeputyIdsAfterMembership([1, 2, 3], 2, false)).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('is a no-op (returns null) when already in the desired state', () => {
|
||||
expect(nextStateDeputyIdsAfterMembership([1, 2], 2, true)).toBeNull()
|
||||
expect(nextStateDeputyIdsAfterMembership([1, 2], 3, false)).toBeNull()
|
||||
})
|
||||
|
||||
it('throws once the leadership is at the state-deputy cap', () => {
|
||||
const atCap = Array.from({ length: MAX_LEADERSHIP_STATE_DEPUTIES }, (_, index) => index + 1)
|
||||
expect(() => nextStateDeputyIdsAfterMembership(atCap, 999, true)).toThrow(
|
||||
`Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`,
|
||||
)
|
||||
})
|
||||
})
|
||||
157
tests/unit/leadershipStateDeputyRelationCell.unit.spec.ts
Normal file
157
tests/unit/leadershipStateDeputyRelationCell.unit.spec.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
LeadershipStateDeputyRelationCell,
|
||||
type RelationCellItem,
|
||||
type RelationCellOption,
|
||||
} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
/**
|
||||
* B37: this cell used to have its own Popover+Command implementation and zero
|
||||
* coverage. It is now a thin wrapper over `RelationChipCell` — the same engine
|
||||
* `MunicipalityPortfolioCell` uses — so these pins cover what changed for it:
|
||||
* the write's field names per `direction`, the party suffix, and the success
|
||||
* feedback the old Popover never gave (no toast, but the live region says so).
|
||||
*/
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
const OPTIONS: RelationCellOption[] = [
|
||||
{
|
||||
id: 10,
|
||||
searchLabel: 'Ana Bastos',
|
||||
item: { id: 10, label: 'Ana Bastos', href: '/campanha/dobradinhas/10' },
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
searchLabel: 'Beto Lima',
|
||||
item: { id: 11, label: 'Beto Lima', href: '/campanha/dobradinhas/11', party: 'PT' },
|
||||
},
|
||||
]
|
||||
|
||||
const items: RelationCellItem[] = [
|
||||
{ id: 10, label: 'Ana Bastos', href: '/campanha/dobradinhas/10' },
|
||||
]
|
||||
|
||||
const cell = (
|
||||
overrides: Partial<{
|
||||
direction: 'fromLeadership' | 'fromStateDeputy'
|
||||
items: RelationCellItem[]
|
||||
membershipAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
}> = {},
|
||||
) =>
|
||||
createElement(LeadershipStateDeputyRelationCell, {
|
||||
direction: overrides.direction ?? 'fromLeadership',
|
||||
fixedId: 7,
|
||||
ownerName: 'Maria Souza',
|
||||
items: overrides.items ?? items,
|
||||
options: OPTIONS,
|
||||
membershipAction: overrides.membershipAction ?? (async () => ({ status: 'success' as const })),
|
||||
measureOverflow: false,
|
||||
})
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('leadership ↔ state deputy relation cell', () => {
|
||||
it('renders each item as a linked chip, with the party suffix when present', () => {
|
||||
render(
|
||||
cell({
|
||||
items: [
|
||||
{ id: 10, label: 'Ana Bastos', href: '/campanha/dobradinhas/10' },
|
||||
{ id: 11, label: 'Beto Lima', href: '/campanha/dobradinhas/11', party: 'PT' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(screen.getByText('Ana Bastos')).toBeTruthy()
|
||||
expect(screen.getByText('Beto Lima (PT)')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('sends leadershipId/stateDeputyId/assigned=false, matching `fromLeadership`', async () => {
|
||||
const membershipAction = vi.fn(
|
||||
async (_state: CampaignFormActionState, _formData: FormData) =>
|
||||
({ status: 'success' as const }) satisfies CampaignFormActionState,
|
||||
)
|
||||
render(cell({ direction: 'fromLeadership', membershipAction }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remover Ana Bastos' }))
|
||||
|
||||
await waitFor(() => expect(membershipAction).toHaveBeenCalledTimes(1))
|
||||
const formData = membershipAction.mock.calls[0]?.[1] as FormData
|
||||
expect(formData.get('leadershipId')).toBe('7')
|
||||
expect(formData.get('stateDeputyId')).toBe('10')
|
||||
expect(formData.get('assigned')).toBe('false')
|
||||
})
|
||||
|
||||
it('swaps the field the fixed id fills for `fromStateDeputy`', async () => {
|
||||
const membershipAction = vi.fn(
|
||||
async (_state: CampaignFormActionState, _formData: FormData) =>
|
||||
({ status: 'success' as const }) satisfies CampaignFormActionState,
|
||||
)
|
||||
render(cell({ direction: 'fromStateDeputy', membershipAction }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remover Ana Bastos' }))
|
||||
|
||||
await waitFor(() => expect(membershipAction).toHaveBeenCalledTimes(1))
|
||||
const formData = membershipAction.mock.calls[0]?.[1] as FormData
|
||||
expect(formData.get('stateDeputyId')).toBe('7')
|
||||
expect(formData.get('leadershipId')).toBe('10')
|
||||
})
|
||||
|
||||
it('adds a searched option and reports the live region as saved', async () => {
|
||||
render(cell())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Editar dobradinhas de Maria Souza' }))
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
const searchInput = within(dialog).getByRole('combobox', { name: 'Buscar deputado estadual' })
|
||||
fireEvent.change(searchInput, { target: { value: 'Beto' } })
|
||||
|
||||
fireEvent.click(await within(dialog).findByRole('option', { name: /Beto Lima/ }))
|
||||
|
||||
await waitFor(() => expect(within(dialog).getByText('Beto Lima (PT)')).toBeTruthy())
|
||||
await waitFor(() => expect(screen.getByRole('status').textContent).toBe('Dobradinhas salvas.'))
|
||||
})
|
||||
|
||||
it('shows the empty dash when there are no items yet', () => {
|
||||
render(cell({ items: [] }))
|
||||
expect(screen.getByText('—')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('undoes a failed removal without reverting a sibling change from the same render', async () => {
|
||||
const membershipAction = vi.fn(
|
||||
async (
|
||||
_state: CampaignFormActionState,
|
||||
_formData: FormData,
|
||||
): Promise<CampaignFormActionState> => ({
|
||||
message: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
|
||||
}),
|
||||
)
|
||||
render(cell({ membershipAction }))
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remover Ana Bastos' }))
|
||||
})
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Ana Bastos')).toBeTruthy())
|
||||
})
|
||||
})
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership'
|
||||
import { MAX_ADVISORS_PER_MUNICIPALITY } from '@/lib/schemas/municipality'
|
||||
|
||||
describe('nextAdvisorIdsAfterMembership', () => {
|
||||
it('adds an advisor not yet present', () => {
|
||||
expect(nextAdvisorIdsAfterMembership([1, 2], 3, true)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('removes an advisor present in the list', () => {
|
||||
expect(nextAdvisorIdsAfterMembership([1, 2, 3], 2, false)).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('is a no-op (returns null) when already in the desired state', () => {
|
||||
expect(nextAdvisorIdsAfterMembership([1, 2], 2, true)).toBeNull()
|
||||
expect(nextAdvisorIdsAfterMembership([1, 2], 3, false)).toBeNull()
|
||||
})
|
||||
|
||||
it('throws once the municipality is at the advisor cap', () => {
|
||||
const atCap = Array.from({ length: MAX_ADVISORS_PER_MUNICIPALITY }, (_, index) => index + 1)
|
||||
expect(() => nextAdvisorIdsAfterMembership(atCap, 999, true)).toThrow(
|
||||
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
|
||||
)
|
||||
})
|
||||
})
|
||||
75
tests/unit/relationMembershipDelta.unit.spec.ts
Normal file
75
tests/unit/relationMembershipDelta.unit.spec.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { nextStateDeputyIdsAfterMembership } from '@/lib/leadershipStateDeputyMembership'
|
||||
import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership'
|
||||
import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership'
|
||||
import {
|
||||
LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
MAX_LEADERSHIP_STATE_DEPUTIES,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import {
|
||||
MAX_ADVISORS_PER_MUNICIPALITY,
|
||||
MAX_STATE_DEPUTIES_PER_MUNICIPALITY,
|
||||
MUNICIPALITY_ADVISORS_CAP_MESSAGE,
|
||||
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
} from '@/lib/schemas/municipality'
|
||||
|
||||
/**
|
||||
* One contract, three relations. Each wrapper only binds its own cap to
|
||||
* `nextIdsAfterMembership`, so the delta behaviour is pinned once and each
|
||||
* wrapper is additionally pinned to refuse at ITS cap with ITS message — the
|
||||
* pair `safeMessages` matches by exact string.
|
||||
*/
|
||||
const relations = [
|
||||
{
|
||||
name: 'nextAdvisorIdsAfterMembership (municipality.advisors)',
|
||||
next: nextAdvisorIdsAfterMembership,
|
||||
max: MAX_ADVISORS_PER_MUNICIPALITY,
|
||||
capMessage: MUNICIPALITY_ADVISORS_CAP_MESSAGE,
|
||||
},
|
||||
{
|
||||
name: 'nextStateDeputyIdsAfterMembership (leadership.stateDeputies)',
|
||||
next: nextStateDeputyIdsAfterMembership,
|
||||
max: MAX_LEADERSHIP_STATE_DEPUTIES,
|
||||
capMessage: LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
},
|
||||
{
|
||||
name: 'nextStateDeputyIdsAfterMunicipalityMembership (municipality.stateDeputies)',
|
||||
next: nextStateDeputyIdsAfterMunicipalityMembership,
|
||||
max: MAX_STATE_DEPUTIES_PER_MUNICIPALITY,
|
||||
capMessage: MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
},
|
||||
] as const
|
||||
|
||||
describe.each(relations)('$name', ({ next, max, capMessage }) => {
|
||||
it('adds an id not yet present', () => {
|
||||
expect(next([1, 2], 3, true)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('removes an id present in the list', () => {
|
||||
expect(next([1, 2, 3], 2, false)).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('is a no-op (returns null) when already in the desired state', () => {
|
||||
expect(next([1, 2], 2, true)).toBeNull()
|
||||
expect(next([1, 2], 3, false)).toBeNull()
|
||||
})
|
||||
|
||||
it('can go to zero', () => {
|
||||
expect(next([1], 1, false)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not mutate the list it was given', () => {
|
||||
const current = [1, 2]
|
||||
next(current, 3, true)
|
||||
next(current, 2, false)
|
||||
expect(current).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('throws its own cap message once at its own cap', () => {
|
||||
const atCap = Array.from({ length: max }, (_, index) => index + 1)
|
||||
expect(() => next(atCap, max + 1, true)).toThrow(capMessage)
|
||||
// At the cap, a removal is still allowed — the ceiling is not a freeze.
|
||||
expect(next(atCap, 1, false)).toHaveLength(max - 1)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue