Merge pull request #370 from fsolla/agent/362-dobradinhas-coluna-municipios
B157 — coluna de dobradinhas na lista de municípios (+ criação inline)
This commit is contained in:
commit
6f81fd3e82
20 changed files with 1602 additions and 142 deletions
145
docs/plans/dobradinhas-coluna-municipios-impl.md
Normal file
145
docs/plans/dobradinhas-coluna-municipios-impl.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Impl: B157 — Coluna de dobradinhas na lista de municípios (+ criação inline)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-04
|
||||
Issue: #362
|
||||
Intenção: docs/plans/dobradinhas-coluna-municipios.md (mergeada no main em 2026-08-04, PR #363 — **revisado contra ela após sync**)
|
||||
Appetite restante: ~1–1,25 dia eng (coluna com display estilo assessores + editor popover/sheet + create inline com partido; sem migration)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** na lista `/campanha/municipios`, uma coluna **"Dobradinhas"** entre "Assessores" e "Tendência" — vínculos de `municipality.stateDeputies` com **busca por nome e partido**, adicionar/remover por delta (auto-save, otimista com rollback + toast) e **criação inline** ("+ Criar dobradinha 'texto'", com `party` opcional via sintaxe **`Nome (PARTIDO)`**), criada e **automaticamente vinculada ao município**. Canvas: `~/.cursor/projects/teqo/canvases/plan-b157-ui-draft.canvas.tsx`.
|
||||
- **Decisão de display do usuário (2026-08-04, gate):** o fechamento da célula segue o **estilo da coluna de Assessores** — agregado de círculos de perfil (iniciais, sobrepostos, máx. 3) com **hover mostrando o detalhe** (nome + partido), não chips na célula. Vazio = "—". O editor (adicionar/remover/criar) abre em **popover no desktop / sheet no mobile**, mesmo gesto do assessor (B27/B154).
|
||||
- **O que NÃO negociar:**
|
||||
- Coluna **somente para `isCampaignUnrestricted`** (coordenador + candidato). Assessor **não vê** (nem read-only — lê na ficha, `MunicipalityStrategyCard`); `leader` também não.
|
||||
- `StateDeputy.name` é unique — nome já existente → erro + toast, sem duplicata.
|
||||
- Não é formulário completo (notas, editar nome/partido inline) → `/campanha/dobradinhas/<slug>`; sem dedup por similaridade; sem filtro/ordenação por dobradinha (gatilho B29).
|
||||
- Sem migration, sem collection nova, sem Consent novo, contratos de URL intactos; relação continua `municipality.stateDeputies`.
|
||||
- **O que reavaliar (Direção no codebase é hipótese):** a intenção hipotetiza control bespoke (`MunicipalityListStateDeputiesControl` espelhando o B27/B154) + endpoint JSON `/campanha/municipios/state-deputies` + record `setMunicipalityStateDeputyMembershipRecord` + `loadStateDeputySummaries` extra. O codebase atual já tem a máquina compartilhada (`RelationChipCell`, extraída no B36/B37) e o caminho de escrita delta **já existe** (`setStateDeputyMunicipalitiesBatch`, B37). B157 monta em cima dos dois, com a **máquina compartilhada ganhando um modo "trigger"** (display fechado custom + overlay popover/sheet) para honrar o display estilo assessores sem duplicar ~400 linhas. **Correção de fato:** a intenção afirma que `canManageCampaignStaffField` é "restrito a `isCampaignUnrestricted`" — no código atual ele é **staff-wide** (o int spec do B37 prova assessor escrevendo). A decisão de produto (coluna só para unrestricted) é UI-gate, sem mudança de access; a superfície B37 (lista de dobradinhas) fica como está — fora deste item.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
page["municipios/page.tsx<br/>+ loadStateDeputyOptions (1×, staff)"] --> cols["municipalityListColumns(props)<br/>coluna stateDeputies · gate isCampaignUnrestricted"]
|
||||
cols --> cell["MunicipalityStateDeputyRelationCell<br/>(wrapper · trigger avatar-stack + tooltip)"]
|
||||
cell -->|"RelationChipCell modo trigger<br/>(popover desktop / sheet mobile)"| fa["setMunicipalityStateDeputiesFormAction<br/>(reusa setStateDeputyMunicipalitiesBatch invertido)"]
|
||||
cell -->|criar inline| ca["createMunicipalityStateDeputyFormAction<br/>parse Nome (PARTIDO) · create+assign em 1 tx"]
|
||||
fa --> reval["revalidateStateDeputyMunicipalityPaths<br/>(já existente, B37)"]
|
||||
ca --> reval
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (modo "trigger" no `RelationChipCell` — display custom + popover/sheet — + reuso do B37 + seams de create) | B (control bespoke `MunicipalityListStateDeputiesControl` espelhando o B27/B154 + endpoint JSON + bridge — a hipótese literal da intenção) | C (chips na célula, container inline+drawer do `RelationChipCell` atual — minha 1ª proposta, **substituída pelo display estilo assessores**)
|
||||
|
||||
**Recomendação:** **A** — o display pedido (círculos + hover) é exatamente o trigger do control de assessores, mas a máquina por trás (delta otimista, undo, combobox ARIA, create, floor/cap) já é compartilhada no `RelationChipCell`. Em vez de duplicar o control do assessor, o `RelationChipCell` ganha um modo opcional: `trigger` (nó de display fechado) + `editorVariant` ('popover' | 'sheet') + `triggerTooltip` — o corpo do editor (chips removíveis + busca + criar) é o mesmo do Drawer de hoje, extraído uma vez. Sem as props novas, B36/B37 se comportam exatamente como hoje.
|
||||
**Rejeitadas:** B — ~400 linhas de máquina duplicada (a mesma que B36/B37 extraíram para matar) + endpoint JSON + bridge de catálogo desnecessária (server action + `revalidatePath` ⇒ refresh RSC atualiza o catálogo de todas as linhas); C — chips na célula contrariam a decisão de display do gate.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**D1 — Display fechado: estilo assessores (círculos + hover), sem chips na célula.**
|
||||
|
||||
- Trigger do `RelationChipCell` (modo trigger): **círculos de iniciais sobrepostos** (`Avatar`/`AvatarFallback` do kit + `campaignUserInitials` — helper genérico de iniciais; `-space-x-2`, `size-8`, máx. 3 visíveis — mesmo visual do `MunicipalityAdvisorAvatarStack`), com `sr-only` listando os nomes.
|
||||
- **Hover = detalhe:** `tooltipContent` do overlay (popover) com uma linha por vínculo — **"Nome (Partido)"** (ex.: "Fulaninho (PT)"), mesmo formato do `formatAdvisorNamesTooltip` mas com partido (helper novo no wrapper). Vazio → "—" (aceite da intenção; sem `MissingAdvisorBadge` — aquilo é semântica de assessor/prioridade).
|
||||
- Link para a ficha `/campanha/dobradinhas/<slug>`: affordance move para o **editor** (os chips removíveis do popover/sheet são links — o `RelationChipCell` já renderiza `href` nos chips), mesmo contrato da coluna de assessores (célula fechada sem link).
|
||||
- Opções consideradas: A (acima) | B (chips na célula, como na lista de dobradinhas) | C (círculos sem tooltip, nome só no editor). Recomendação: **A** — é o pedido literal do gate ("círculos + hover para ver o detalhe"); B foi a proposta anterior, substituída; C perderia a leitura sem abrir (o hover do assessor existe justamente para isso — B23).
|
||||
|
||||
**D2 — Editor: modo "trigger" no `RelationChipCell` (popover desktop / sheet mobile).**
|
||||
|
||||
- Novas props opcionais no `RelationChipCell`: `trigger?: (chips, isPending) => ReactNode` (display fechado custom), `editorVariant?: 'popover' | 'sheet'`, `triggerLabel: string`, `triggerTooltip?: ReactNode`. Com `trigger` presente: renderiza o nó custom envolvido num `CampaignCellEditOverlay` do variant pedido (precedente: o control de assessores recebe `variant="popover"` no desktop e `variant="sheet"` nos cards) com o **corpo do editor** (chips removíveis + combobox + criar) — o mesmo JSX do Drawer atual, extraído para um renderer compartilhado `editorBody`; sem `trigger`, comportamento inline+drawer atual intacto.
|
||||
- Call sites: tabela desktop passa `editorVariant="popover"`; cards mobile `editorVariant="sheet"` (o `CampaignListSheetProvider` do `MunicipalityListMobileSection` já hospeda o sheet — **nenhum provider novo no desktop**, já que o popover não usa Drawer; remove o item de risco da revisão anterior).
|
||||
- Opções: A (modo trigger na máquina compartilhada) | B (control bespoke completo) | C (manter inline+drawer com chips). Recomendação: **A** — mesmo gesto do assessor (popover/sheet) com a máquina compartilhada; B duplica; C foi superado pelo display do gate.
|
||||
|
||||
**D3 — Busca: por nome E partido.**
|
||||
Opções: A) `matchesNormalizedAtWordStart` sobre `plainName` **ou** `party` normalizados (WeakMap de labels pré-normalizados, padrão do irmão B36) | B) só nome (padrão B31).
|
||||
Recomendação: **A** — a intenção e o canvas dizem "filtrando por nome e partido"; digitar "PT" acha "Fulano (PT)". Hit label = `Nome (Partido)`.
|
||||
|
||||
**D4 — Delta: reusar `setStateDeputyMunicipalitiesBatch` invertido.**
|
||||
Opções: A) wrapper de form action `setMunicipalityStateDeputiesFormAction` na rota municipios mapeando `{ municipalityId, stateDeputyId, assigned }` → `setStateDeputyMunicipalitiesBatch({ stateDeputyId, municipalityIds: [municipalityId], assigned })` | B) record novo `setMunicipalityStateDeputyMembershipRecord` (a hipótese da intenção).
|
||||
Recomendação: **A** — o record B37 já faz tudo (`acquireTextAdvisoryLocks(['municipality-state-deputies:{id}'])`, `user` + `overrideAccess: false`, cap via `nextStateDeputyIdsAfterMunicipalityMembership`, no-op sem revalidate, `revalidateStateDeputyMunicipalityPaths`). Wrapper novo ~20 linhas em `municipalityStaffFormActions.ts`. B é twin.
|
||||
|
||||
**D5 — Criação inline: parser `Nome (PARTIDO)` + seams no `RelationChipCell` + record atômico.**
|
||||
|
||||
- **Parser puro** `parseStateDeputyNameParty(text)` em **`src/lib/stateDeputyNameParty.ts`** (client-safe): extrai UM grupo `(...)` final → `{ name, party }`; sem parênteses → party `null`; partido trimado, ≤32. Usado pelo schema zod (server, autoridade) e pelo chip temp (cliente).
|
||||
- **`municipalityStateDeputyCreateSchema`** (`src/lib/schemas/stateDeputy.ts`): `{ municipalityId, rawName }` → transform → `{ municipalityId, name, party }`, name 2..160 pós-parse, party ≤32; `(PT)` sozinho → name vazio → erro de validação (toast).
|
||||
- **`createMunicipalityStateDeputyRecord`** (`actions/stateDeputy.ts`): 1 transação — `reloadStaffActor` (staff; `STATE_DEPUTY_STAFF_MESSAGE`) → lock `municipality-state-deputies:{id}` → `payload.create('stateDeputy', { name, ...(party ? { party } : {}) })` com `user`/`overrideAccess: false`, conflict (unique name/slug) → `STATE_DEPUTY_CONFLICT_MESSAGE` → leitura do município + delta (`nextStateDeputyIdsAfterMunicipalityMembership`) + update — espelho de `createMunicipalityAdvisorRecord` (B154): se a atribuição falhar, o create **rola junto** (sem órfão). Retorna `{ stateDeputy: { id, name, slug, party }, municipalitySlug }`; wrapper revalida via `revalidateStateDeputyMunicipalityPaths`.
|
||||
- **`RelationChipCell`**: seams opcionais `createAction?` + `buildCreateFormData?`; quando `hits.length === 0` e query 2..160 → item **`Criar dobradinha “{query}”`** no lugar do "Nenhum resultado" (padrão B154), no corpo do editor (popover e drawer); chip temp sem remover (precedente B154); sucesso → swap para id real + delta otimista + mapa de criados (filtro `!ids.includes(id) && effectiveIds.includes(id)` — sem chip fantasma pós-remoção, sem duplicata pós-refresh); erro → revert + feedback/toast existentes. Zero mudança de comportamento sem as props novas.
|
||||
- **Sem bridge provider** (diferença estrutural vs. B154): server action + `revalidatePath` ⇒ refresh RSC ⇒ `loadStateDeputyOptions` re-carrega ⇒ catálogo atualizado em todas as linhas.
|
||||
|
||||
**D6 — Dados: `stateDeputyIDs` no view model + catálogo como fonte única.**
|
||||
Opções: A) `municipalityListSelect` += `stateDeputies: true`; `MunicipalityListViewModel` += `stateDeputyIDs: number[]`; página carrega `loadStateDeputyOptions(payload, user)` 1× e passa como prop (factory de colunas + mobile) | B) também `loadStateDeputySummaries` (hipótese da intenção).
|
||||
Recomendação: **A** — o catálogo (dezenas de registros, `plainName`+`party`+`slug`) cobre display, tooltip e busca; zero query por linha; payload = ids inteiros. B é query duplicada (adiada no `/simplify` do B31).
|
||||
|
||||
**D7 — Coluna: id/label/description/picker + posição + gate + mobile.**
|
||||
|
||||
- `MunicipalityListColumnId` += `'stateDeputies'` (B17: id = chave do picker = cookie = description); `municipalityColumnLabels` += `'Dobradinhas'`; `municipalityColumnDescriptions` += entrada (B22).
|
||||
- Posição: **entre `advisors` e `trend`** (decisão A da intenção, "grupo rede"); head sem sort/filtro (`CampaignTableHead` + description). **Sem `cellTooltip` na column def** — a célula embrulha o próprio tooltip no overlay (mesmo comentário da coluna de assessores — não dobrar no mesmo gesto).
|
||||
- **Gate `isCampaignUnrestricted`** (coordenador + candidato — explícito na intenção; diferente do popover de assessores que é `isCoordinator`): coluna desktop, entrada mobile e entrada do picker só nesse branch. `MunicipalityListProps` += `isCampaignUnrestricted: boolean` (a página já computa o mesmo predicado para `canMoveEngagementLevel`).
|
||||
- Mobile: entrada "Dobradinhas" no `dl` staff de `MunicipalityListMobileCards` com `editorVariant="sheet"` (o `CampaignListSheetProvider` do `MunicipalityListMobileSection` já hospeda o Drawer compartilhado — miss #52 respeitada).
|
||||
|
||||
**D8 — Revalidação e locks: nenhuma peça nova.** Delta e create revalidam via `revalidateStateDeputyMunicipalityPaths` (já em `actions/stateDeputy.ts`): lista de municípios + detalhe do município tocado + ficha da dobradinha. Lock `municipality-state-deputies:{id}` serializa toggles concorrentes na mesma linha.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/lib/stateDeputyNameParty.ts`** (novo, puro): `parseStateDeputyNameParty` + unit tests (bordas: sem parênteses, `(PT)` sozinho, parênteses no meio, grupo final com espaços, party >32).
|
||||
- **`src/lib/schemas/stateDeputy.ts`**: `municipalityStateDeputyCreateSchema` (com transform do parser).
|
||||
- **`src/utilities/municipality/municipalityViewModels.ts`**: `municipalityListSelect` += `stateDeputies: true`; `MunicipalityListViewModel` += `stateDeputyIDs: number[]`; `toMunicipalityListViewModel` mapeia (padrão `advisorIDs`).
|
||||
- **`src/utilities/municipality/municipalityLabels.ts`**: `MunicipalityListColumnId` += `'stateDeputies'`; `municipalityColumnDescriptions` += entrada.
|
||||
- **`src/utilities/municipality/municipalityListUrl.ts`**: `municipalityColumnLabels` += `stateDeputies: 'Dobradinhas'`.
|
||||
- **`src/app/(campaign)/campanha/actions/stateDeputy.ts`**: `createMunicipalityStateDeputyRecord` + wrapper `createMunicipalityStateDeputy` (revalida via `revalidateStateDeputyMunicipalityPaths`).
|
||||
- **`src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts`**: `setMunicipalityStateDeputiesFormAction` (delta, reusa o batch) + `createMunicipalityStateDeputyFormAction` (create+assign; success devolve `stateDeputy` via o generic do `runCampaignFormAction`).
|
||||
- **`src/components/campaign/shared/RelationChipCell.tsx`**: (1) **modo trigger** — props opcionais `trigger?`/`editorVariant?`/`triggerLabel?`/`triggerTooltip?`; corpo do editor extraído para renderer compartilhado (chips removíveis + combobox + criar) usado pelo Drawer atual e pelo overlay do modo trigger (`CampaignCellEditOverlay` com o variant pedido); (2) **seams de create** — `createAction?`/`buildCreateFormData?` + item "Criar…" + chip temp + swap + mapa de criados. Sem as props novas, comportamento atual idêntico.
|
||||
- **`src/components/campaign/shared/MunicipalityStateDeputyRelationCell.tsx`** (novo): wrapper fino — `buildChips` (label `Nome (Partido)`, href da ficha), `searchHits` (nome **ou** partido, WeakMap de labels), `buildFormData` (`municipalityId`+`stateDeputyId`+`assigned`), `buildCreateFormData` (`municipalityId`+`rawName`), **trigger display** (círculos de iniciais `Avatar`/`AvatarFallback` + `campaignUserInitials`, `-space-x-2`, máx. 3, `sr-only` com nomes; vazio "—") e **tooltip de detalhe** ("Nome (Partido)" por linha, helper `formatStateDeputyNamesTooltip`), copy pt-BR, `triggerLabel` "Editar dobradinhas em {nome}".
|
||||
- **`src/components/campaign/municipality/MunicipalityList.tsx`**: coluna `stateDeputies` (gate `isCampaignUnrestricted`, entre `advisors` e `trend`); props novas (`stateDeputyOptions`, `stateDeputyCreateAction`, `isCampaignUnrestricted`); editor `variant="popover"` no desktop.
|
||||
- **`src/components/campaign/municipality/MunicipalityListMobileCards.tsx`** (+ `MobileSection`): entrada "Dobradinhas" no dl (gate unrestricted, `editorVariant="sheet"`); props repassadas.
|
||||
- **`src/app/(campaign)/campanha/(app)/municipios/page.tsx`**: `loadStateDeputyOptions` no `Promise.all` (staff view); props na lista.
|
||||
- **Migration:** sem migration (relação já existe desde E4R).
|
||||
- **Access / Consent:** nenhuma peça nova; o create herda `canCreateStateDeputy` (staff) e o delta herda `canUpdateMunicipality` — ambos com `user`/`overrideAccess: false`; a intenção fica satisfeita no nível da UI (gate `isCampaignUnrestricted`). **Observação registrada (não escopo):** a API continua permitindo assessor escrever em `municipality.stateDeputies` (B37) — se produto quiser restringir, é item separado.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- Forma: **tabela** — célula fechada com círculos de perfil + hover com o detalhe "Nome (Partido)" (decisão do gate; precedente `MunicipalityAdvisorAvatarStack`). Rejeitado: chips na célula (display anterior, superado); contagem ("3 dobradinhas" esconde o nome que decide); KPI/chart (dado categórico nominal).
|
||||
- Busca: nome **e** partido (D3); vazio = "—" (aceite da intenção).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Server + view model + testes de domínio** — parser puro + schemas, select/view model, record create+assign, wrappers de form action, revalidate; int tests (`campaignMunicipalityStateDeputyCreate.int.spec.ts`): coordinator/candidate criam+atribuem; assessor fora do escopo → **rollback do create**; leader recusado; nome duplicado → `STATE_DEPUTY_CONFLICT_MESSAGE`; parse do partido persiste (`party` correto); delta de 1 município via wrapper (no-op sem revalidate). `pnpm gate:fast` parcial.
|
||||
2. **UI** — modo trigger + seams de create no `RelationChipCell` → wrapper (display + tooltip + busca) → coluna + picker + labels/descriptions + mobile; unit pins (`campaignComponents.unit.spec.ts`: fixtures `stateDeputyIDs: []`; **tooltip count staff segue 10** — a coluna nova só renderiza com `isCampaignUnrestricted: true`, novo teste com count 11; leader segue 4; `municipalityListDefaultProps` ganha `stateDeputyOptions: []` + `isCampaignUnrestricted: false` + stub create action; teste do display: iniciais + sr-only + vazio "—").
|
||||
3. **Gates** — unit do parser; e2e espelhando o teste B154 (coordenador abre "Editar dobradinhas em {município}", cria "Cicrano (PCdoB)" inline, chip aparece no popover, busca por partido acha, "Criar" some após criar, remover grava) em `campaignMunicipalities.e2e.spec.ts`; `pnpm gate:fast` → `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Sort/filtro por dobradinhas no header** — gatilho B29 registrado na própria intenção; não inventar.
|
||||
- **Coluna para assessor (read-only)** — corte explícito da intenção; não adicionar `cellTooltip`/variante.
|
||||
- **Partido com formatação complexa** — regra do parser documentada (um grupo `(...)` final; resto é name); `(PT)` sozinho → erro de validação.
|
||||
- **B155 (#359, in-progress)** — coluna irmã na mesma tabela; escopos disjuntos (ids/labels/view model próprios); B155 sem branch de implementação — conflitos mecânicos de merge apenas.
|
||||
- **Bridge provider de criados (padrão B154)** — desnecessário (server action + revalidatePath).
|
||||
- **Renomear/estender `LeadershipStateDeputyRelationCell`** — código landed B36/B37; sem ganho de produto.
|
||||
- **`loadStateDeputySummaries` por IDs na lista** — query duplicada; o catálogo cobre.
|
||||
- **Restringir access de assessor na API (B37)** — item separado; só registro no gate.
|
||||
- **Adotar o modo trigger no control de assessores (B27/B154)** — o control bespoke continua como está; migrá-lo para o `RelationChipCell` é item próprio (tem seq/reconcile de endpoint JSON que o modo trigger não precisa).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **`RelationChipCell` é compartilhado (B36/B37)** — modo trigger e seams de create só com props opcionais; comportamento atual inalterado; pins de unit/integ cobrem os consumidores.
|
||||
- **Modo trigger vs. drawer existente** — o corpo do editor é extraído UMA vez e compartilhado; testes existentes do drawer continuam valendo (mesmo renderer).
|
||||
- **Chip temp × medição de overflow** — em modo trigger não há medição de linha (o display fechado é o trigger); o chip temp vive dentro do editor (chips + busca), sem remove (precedente B154).
|
||||
- **Colisão de nome na criação inline** — busca vazia ⇒ nome fora do catálogo; conflito só em corrida → `STATE_DEPUTY_CONFLICT_MESSAGE` no `safeMessages`.
|
||||
- **Guarda `sharedSheetHostConventions`** — só o sheet (mobile) exige provider, e o `MunicipalityListMobileSection` já tem; desktop usa popover, sem provider novo.
|
||||
|
||||
## Revisão na entrega (2026-08-04)
|
||||
|
||||
O plano acima executou como escrito, com ajustes do gate e do `/simplify` (3 revisores paralelos — qualidade, performance, reuso):
|
||||
|
||||
- **Gate do usuário:** display da coluna mudou para o estilo assessores (círculos + hover) — `RelationChipCell` ganhou o modo "trigger" (display fechado custom + overlay popover/sheet com o corpo do editor compartilhado) em vez de chips inline. Sem `CampaignListSheetProvider` novo no desktop (popover não usa Drawer).
|
||||
- **Correção de fato:** Payload valida unique ANTES do insert com um `ValidationError` localizado ("O campo a seguir está inválido: name") — o pattern do policy não pega o caso normal de duplicata. O mapeamento de conflito foi movido para o dono (`mapStaffEntityConflict` em `campaignEntityActions.ts`, usado por `runStaffEntityMutation` e pelo record novo) — os formulários de organização/dobradinha herdam a mensagem de conflito por tabela.
|
||||
- **`/simplify` fixados:** (1) raça do `createdChips` (create→remove rápido→re-add duplicava o chip com a mesma key) — o copy agora cai quando o catálogo (`baseIds`) resolve o id, sem depender de `ids` chegar; (2) a11y — os nomes atribuídos vão no `aria-label` do trigger ("Editar dobradinhas em X — Fulano (PT)"), padrão do assessor; `sr-only` morto removido; (3) cleanup do `createdChips` com bail de no-op (B36/B37 não pagam render pass); (4) supressão do "Criar…" por **igualdade normalizada** com chip atribuído (prefixo ainda permite criar nome novo) e gate para não rodar sem wiring de create; (5) fold "Nome (Partido)" em fonte única (`stateDeputyDisplayName` no lib; `option.name` do catálogo reusado); (6) `optionById` compartilhado por WeakMap entre linhas; (7) cap message no `safeMessages` do create; (8) statusMessage com "opção de criar" quando o único item é o create; (9) padding único do corpo do editor (16px nas duas superfícies); (10) `MunicipalityStateDeputyCreateAction` tipo único exportado; (11) `payload-types.ts` fora do diff (drift de regeneração não relacionado, revertido).
|
||||
- **Adiados com gatilho (ver débitos):** extração de `AvatarStack` genérico (2º call site — regra do repo); migração do `itemLabel` do irmão B36 para `stateDeputyDisplayName` (próximo toque no arquivo); cache do catálogo (medição); bounds do schema via `pick` (wording pt-BR); adapter de busca em `lib/` (próximo par de relação); **observação de produto:** a API continua permitindo assessor escrever `municipality.stateDeputies` (B37) — a intenção supunha `isCampaignUnrestricted`; se produto quiser fechar, é item separado.
|
||||
|
||||
Gate completo verde (tsc, lint --max-warnings=0, format:check, check:cycles, knip com o P3 pré-existente, unit 1358/1358, int 573/573, e2e B157 + vizinhos, build). Aikido: CLI não disponível no ambiente — padrões idênticos aos do B37/B154 já escaneados.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (coluna entre Assessores e Tendência, display estilo assessores — círculos + hover com "Nome (Partido)", vazio "—", busca nome+partido, auto-save por delta otimista com rollback+toast, criar inline `Nome (PARTIDO)` com vínculo automático, gate `isCampaignUnrestricted`, unique name → toast)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (`overrideAccess: false` com `user`, transação no create+assign, sem migration, sem Consent, identificadores en/strings pt-BR, cap respeitado)
|
||||
- [ ] Testes de domínio: int do create+assign (roles, rollback, conflito, partido), unit do parser e dos pins da lista; e2e do create inline
|
||||
- [ ] Self-score decision-quality: decisões caras com rejeitadas (D1–D8) · cabe no appetite (~1–1,25 dia) · rabbit holes nomeados · depth check reusa `RelationChipCell`/batch B37/`runCampaignFormAction`/`withPayloadTransaction`/locks · intenção preservada (revisado contra `dobradinhas-coluna-municipios.md` + canvas + decisão de display do gate)
|
||||
|
|
@ -6,20 +6,34 @@ import {
|
|||
setMunicipalityPoliticalTrend,
|
||||
} from '@/app/(campaign)/campanha/actions/municipality'
|
||||
import { createMunicipalityUpdate } from '@/app/(campaign)/campanha/actions/municipalityUpdate'
|
||||
import {
|
||||
createMunicipalityStateDeputy,
|
||||
setStateDeputyMunicipalitiesBatch,
|
||||
type MunicipalityStateDeputyCreateResult,
|
||||
} from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import {
|
||||
nullableFormText,
|
||||
optionalFormText,
|
||||
optionalMunicipalitySlugFromForm,
|
||||
repeatedRelationshipFormValues,
|
||||
requiredFormBoolean,
|
||||
requiredFormText,
|
||||
requiredRelationshipFormValue,
|
||||
voteEstimateScenarioFromForm,
|
||||
} from '@/lib/formData'
|
||||
import { WIZARD_TREND_SAVED_MESSAGE } from '@/lib/politicalTrendWizardUi'
|
||||
import {
|
||||
MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE,
|
||||
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
parsePoliticalTrendStatusFormValue,
|
||||
} from '@/lib/schemas/municipality'
|
||||
import { parseMunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
|
||||
import {
|
||||
STATE_DEPUTY_CONFLICT_MESSAGE,
|
||||
STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
|
||||
STATE_DEPUTY_NAME_REQUIRED_MESSAGE,
|
||||
STATE_DEPUTY_STAFF_MESSAGE,
|
||||
} from '@/lib/schemas/stateDeputy'
|
||||
import { WIZARD_SIGNAL_SAVED_MESSAGE } from '@/lib/wizardSignalUi'
|
||||
import {
|
||||
runCampaignFormAction,
|
||||
|
|
@ -106,3 +120,66 @@ export const createMunicipalityListSignalFormAction = async (
|
|||
},
|
||||
genericMessage: 'Não foi possível registrar o sinal. Verifique seu acesso e tente novamente.',
|
||||
})
|
||||
|
||||
/**
|
||||
* B157 — one chip toggle in the "Dobradinhas" column of `/campanha/municipios`.
|
||||
* Deliberately NOT a new record: the B37 batch already owns the inverted write
|
||||
* (`stateDeputyId` + `municipalityIds[]`), so this wrapper maps the owner-shaped
|
||||
* delta of the cell (`municipalityId` + `stateDeputyId`) onto it — the lock,
|
||||
* the advisor-scope check, the cap and the revalidation all stay where B37 put
|
||||
* them (and `setStateDeputyMunicipalitiesBatch` already revalidates the list,
|
||||
* the touched detail and the deputy's ficha).
|
||||
*/
|
||||
export const setMunicipalityStateDeputiesFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
const municipalityId = requiredRelationshipFormValue(formData, 'municipalityId')
|
||||
const stateDeputyId = requiredRelationshipFormValue(formData, 'stateDeputyId')
|
||||
const assigned = requiredFormBoolean(formData, 'assigned')
|
||||
|
||||
await setStateDeputyMunicipalitiesBatch({
|
||||
stateDeputyId,
|
||||
municipalityIds: [municipalityId],
|
||||
assigned,
|
||||
})
|
||||
return { message: assigned ? 'Dobradinha vinculada.' : 'Dobradinha removida.' }
|
||||
},
|
||||
safeMessages: STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
|
||||
genericMessage:
|
||||
'Não foi possível atualizar as dobradinhas. Verifique seu acesso e tente novamente.',
|
||||
})
|
||||
|
||||
/**
|
||||
* B157 — inline create from the "Dobradinhas" column: the raw search text
|
||||
* (`Nome (PARTIDO)` syntax) is parsed and persisted with the automatic link to
|
||||
* the município. The success state carries the created deputy so the cell can
|
||||
* swap its optimistic chip without waiting for the RSC refresh.
|
||||
*/
|
||||
export const createMunicipalityStateDeputyFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<
|
||||
CampaignFormActionState & { stateDeputy?: MunicipalityStateDeputyCreateResult['stateDeputy'] }
|
||||
> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
const stateDeputy = await createMunicipalityStateDeputy({
|
||||
municipalityId: requiredRelationshipFormValue(formData, 'municipalityId'),
|
||||
rawName: requiredFormText(formData, 'rawName'),
|
||||
})
|
||||
return {
|
||||
message: 'Dobradinha criada e vinculada.',
|
||||
stateDeputy: stateDeputy.stateDeputy,
|
||||
}
|
||||
},
|
||||
safeMessages: [
|
||||
STATE_DEPUTY_CONFLICT_MESSAGE,
|
||||
STATE_DEPUTY_STAFF_MESSAGE,
|
||||
STATE_DEPUTY_NAME_REQUIRED_MESSAGE,
|
||||
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
],
|
||||
genericMessage: 'Não foi possível criar a dobradinha. Tente novamente.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
} from '@/utilities/campaignAccess'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions'
|
||||
import {
|
||||
buildMunicipalityListVisitHref,
|
||||
buildMunicipalityListVisitLabel,
|
||||
|
|
@ -39,7 +40,11 @@ import {
|
|||
getEligibleLeadershipOptions,
|
||||
loadAdvisorSummaries,
|
||||
} from '@/utilities/municipality/municipalityViewModels'
|
||||
import { createMunicipalityListSignalFormAction } from './municipalityStaffFormActions'
|
||||
import {
|
||||
createMunicipalityListSignalFormAction,
|
||||
createMunicipalityStateDeputyFormAction,
|
||||
setMunicipalityStateDeputiesFormAction,
|
||||
} from './municipalityStaffFormActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('municipios')
|
||||
|
||||
|
|
@ -82,14 +87,18 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
...filterFacets.advisorIDs,
|
||||
]),
|
||||
]
|
||||
const [advisorSummaries, advisorOptions, leadershipOptions] = await Promise.all([
|
||||
isStaffView ? loadAdvisorSummaries(payload, user, advisorIDs) : [],
|
||||
// Only the coordinator-only assign-advisors control consumes these.
|
||||
isCoordinator ? getEligibleAdvisorOptions(payload, user) : [],
|
||||
// All staff edit the Lideranças column (scoped server-side); the leader
|
||||
// never reaches this page (`gate: noLeader`).
|
||||
isStaffView ? getEligibleLeadershipOptions(payload, user) : [],
|
||||
])
|
||||
const [advisorSummaries, advisorOptions, leadershipOptions, stateDeputyOptions] =
|
||||
await Promise.all([
|
||||
isStaffView ? loadAdvisorSummaries(payload, user, advisorIDs) : [],
|
||||
// Only the coordinator-only assign-advisors control consumes these.
|
||||
isCoordinator ? getEligibleAdvisorOptions(payload, user) : [],
|
||||
// All staff edit the Lideranças column (scoped server-side); the leader
|
||||
// never reaches this page (`gate: noLeader`).
|
||||
isStaffView ? getEligibleLeadershipOptions(payload, user) : [],
|
||||
// B157 — the Dobradinhas column is coordinator + candidate only; the
|
||||
// catalog serves the avatar-stack display, the tooltip and the search.
|
||||
canMoveEngagementLevel ? loadStateDeputyOptions(payload, user) : [],
|
||||
])
|
||||
const advisorNamesById = new Map(advisorSummaries.map((advisor) => [advisor.id, advisor]))
|
||||
|
||||
const selectedAdvisorIDs = new Set(state.advisors ?? [])
|
||||
|
|
@ -118,7 +127,10 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
trailing={
|
||||
<CampaignColumnPickerTrailing
|
||||
columnVisibility={columnVisibility}
|
||||
columns={municipalityListPickerColumns({ isStaffView })}
|
||||
columns={municipalityListPickerColumns({
|
||||
isStaffView,
|
||||
isCampaignUnrestricted: canMoveEngagementLevel,
|
||||
})}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
@ -134,9 +146,13 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
leadershipNamesById={leadershipNamesById}
|
||||
isStaffView={isStaffView}
|
||||
isCoordinator={isCoordinator}
|
||||
isCampaignUnrestricted={canMoveEngagementLevel}
|
||||
canMoveEngagementLevel={canMoveEngagementLevel}
|
||||
advisorOptions={advisorOptions}
|
||||
leadershipOptions={leadershipOptions}
|
||||
stateDeputyOptions={stateDeputyOptions}
|
||||
stateDeputyCommitAction={setMunicipalityStateDeputiesFormAction}
|
||||
stateDeputyCreateAction={createMunicipalityStateDeputyFormAction}
|
||||
columnFilterOptions={columnFilterOptions}
|
||||
signalFormAction={createMunicipalityListSignalFormAction}
|
||||
state={state}
|
||||
|
|
|
|||
|
|
@ -8,16 +8,22 @@ import { uniqueRelationshipIds } from '@/lib/relationship'
|
|||
import {
|
||||
STATE_DEPUTY_CONFLICT_MESSAGE,
|
||||
STATE_DEPUTY_STAFF_MESSAGE,
|
||||
municipalityStateDeputyCreateSchema,
|
||||
stateDeputyCreateSchema,
|
||||
stateDeputyMunicipalitiesBatchSchema,
|
||||
stateDeputyUpdateSchema,
|
||||
type MunicipalityStateDeputyCreateInput,
|
||||
type StateDeputyCreateInput,
|
||||
type StateDeputyMunicipalitiesBatchInput,
|
||||
type StateDeputyUpdateInput,
|
||||
} from '@/lib/schemas/stateDeputy'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
|
||||
import { runStaffEntityMutation, type StaffEntityPolicy } from '@/utilities/campaignEntityActions'
|
||||
import {
|
||||
mapStaffEntityConflict,
|
||||
runStaffEntityMutation,
|
||||
type StaffEntityPolicy,
|
||||
} from '@/utilities/campaignEntityActions'
|
||||
import { hookFilledCreateData } from '@/utilities/hookFilledData'
|
||||
import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
|
|
@ -204,3 +210,105 @@ export const setStateDeputyMunicipalitiesBatch = async (
|
|||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* B157 — name-only (+ party via `Nome (PARTIDO)`) inline create from the
|
||||
* "Dobradinhas" column of `/campanha/municipios`. Creates the `stateDeputy`
|
||||
* and assigns it to the município in the SAME transaction: if the assign
|
||||
* fails (out-of-scope município, cap), the create rolls back with it — no
|
||||
* orphan, same contract as `createMunicipalityAdvisorRecord` (B154). The
|
||||
* unique `name`/`slug` violation is mapped to the safe conflict message; the
|
||||
* município write re-checks `canUpdateMunicipality` through the same
|
||||
* `user`-threaded read as the B37 batch.
|
||||
*/
|
||||
export const createMunicipalityStateDeputyRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: MunicipalityStateDeputyCreateInput,
|
||||
) => {
|
||||
const { municipalityId, name, party } = municipalityStateDeputyCreateSchema.parse(input)
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await reloadStaffActor(payload, actor, STATE_DEPUTY_STAFF_MESSAGE, req)
|
||||
|
||||
await acquireTextAdvisoryLocks(payload, req, [
|
||||
`municipality-state-deputies:${municipalityId}`,
|
||||
])
|
||||
|
||||
let created
|
||||
try {
|
||||
created = await payload.create({
|
||||
collection: 'stateDeputy',
|
||||
data: hookFilledCreateData<'stateDeputy'>({ name, ...(party ? { party } : {}) }),
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
} catch (error) {
|
||||
// Same translation as the shared owner (`runStaffEntityMutation`): the
|
||||
// pattern covers the insert race, `ValidationError` covers Payload's
|
||||
// pre-insert unique check — in this create path the only validation
|
||||
// left after zod IS the unique name/slug check.
|
||||
const conflict = mapStaffEntityConflict(error, stateDeputyPolicy)
|
||||
if (conflict) throw conflict
|
||||
throw error
|
||||
}
|
||||
|
||||
const municipality = await payload.findByID({
|
||||
collection: 'municipality',
|
||||
id: municipalityId,
|
||||
depth: 0,
|
||||
select: { stateDeputies: true, slug: true },
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
|
||||
const nextStateDeputyIDs = nextStateDeputyIdsAfterMunicipalityMembership(
|
||||
uniqueRelationshipIds(municipality.stateDeputies),
|
||||
created.id,
|
||||
true,
|
||||
)
|
||||
// Unreachable — the created id cannot already be assigned — but mirrors
|
||||
// the B154 twin's no-write return so the null branch is honest.
|
||||
if (nextStateDeputyIDs !== null) {
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: municipalityId,
|
||||
data: { stateDeputies: nextStateDeputyIDs },
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
stateDeputy: {
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
slug: created.slug,
|
||||
party: created.party ?? null,
|
||||
},
|
||||
municipalitySlug: typeof municipality.slug === 'string' ? municipality.slug : undefined,
|
||||
}
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar a criação da dobradinha.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const createMunicipalityStateDeputy = async (input: MunicipalityStateDeputyCreateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const result = await createMunicipalityStateDeputyRecord(payload, actor, input)
|
||||
if (result.municipalitySlug) {
|
||||
revalidateStateDeputyMunicipalityPaths(result.stateDeputy.slug, [result.municipalitySlug])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export type MunicipalityStateDeputyCreateResult = Awaited<
|
||||
ReturnType<typeof createMunicipalityStateDeputyRecord>
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
|
||||
import { APIError } from 'payload'
|
||||
|
||||
import { STATE_DEPUTY_NAME_REQUIRED_MESSAGE } from '@/lib/schemas/stateDeputy'
|
||||
import { slugify } from '@/lib/slug'
|
||||
import { trimmedText } from '@/lib/text'
|
||||
import {
|
||||
|
|
@ -21,7 +22,7 @@ const setCanonicalStateDeputySlug: CollectionBeforeValidateHook = ({
|
|||
const name = trimmedText(data.name ?? originalDoc?.name)
|
||||
const slug = slugify(name)
|
||||
if (!slug) {
|
||||
throw new APIError('Informe um nome com letras ou números.', 400)
|
||||
throw new APIError(STATE_DEPUTY_NAME_REQUIRED_MESSAGE, 400)
|
||||
}
|
||||
if (operation === 'update' && data.name !== undefined && name !== originalDoc?.name) {
|
||||
throw new APIError('O nome da dobradinha não pode ser alterado após a criação.', 409)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ import {
|
|||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import {
|
||||
MunicipalityStateDeputyRelationCell,
|
||||
type MunicipalityStateDeputyCreateAction,
|
||||
} from '@/components/campaign/shared/MunicipalityStateDeputyRelationCell'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
|
|
@ -48,6 +52,7 @@ import {
|
|||
import { formatEngagementLevelLabel } from '@/lib/engagementLevel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
|
||||
import {
|
||||
formatTerritorialClassWhy,
|
||||
municipalityColumnDescriptions,
|
||||
|
|
@ -100,9 +105,15 @@ export type MunicipalityListProps = {
|
|||
* quietly hiding the control from the candidate.
|
||||
*/
|
||||
canMoveEngagementLevel: boolean
|
||||
/** B157 — the Dobradinhas column is coordinator + candidate only (intention aceite). */
|
||||
isCampaignUnrestricted: boolean
|
||||
advisorOptions: EligibleAdvisorOption[]
|
||||
/** B155 — every leadership the actor may add, for the Lideranças popover. */
|
||||
leadershipOptions: EligibleLeadershipOption[]
|
||||
/** B157 — the dobradinha catalog: chips, tooltip and search all read it. */
|
||||
stateDeputyOptions: StateDeputyRelationOption[]
|
||||
stateDeputyCommitAction: MunicipalityStaffFormAction
|
||||
stateDeputyCreateAction: MunicipalityStateDeputyCreateAction
|
||||
columnFilterOptions: MunicipalityColumnFilterOptions
|
||||
signalFormAction: MunicipalityStaffFormAction
|
||||
state: MunicipalityListState
|
||||
|
|
@ -152,12 +163,16 @@ const municipalityListColumns = ({
|
|||
state,
|
||||
isStaffView,
|
||||
isCoordinator,
|
||||
isCampaignUnrestricted,
|
||||
canMoveEngagementLevel,
|
||||
columnFilterOptions,
|
||||
advisorNamesById,
|
||||
advisorOptions,
|
||||
leadershipNamesById,
|
||||
leadershipOptions,
|
||||
stateDeputyOptions,
|
||||
stateDeputyCommitAction,
|
||||
stateDeputyCreateAction,
|
||||
signalFormAction,
|
||||
}: MunicipalityListProps): Array<MunicipalityColumn> => [
|
||||
{
|
||||
|
|
@ -320,6 +335,35 @@ const municipalityListColumns = ({
|
|||
? null
|
||||
: formatAdvisorNamesTooltip(advisorEntries(municipality, advisorNamesById)),
|
||||
},
|
||||
...(isCampaignUnrestricted
|
||||
? ([
|
||||
{
|
||||
// B157 — "who doubles with us here", next to "who takes care of
|
||||
// it": the advisors-style avatar stack + hover detail, with an
|
||||
// editor (Popover on desktop / Drawer on mobile) that also
|
||||
// creates dobradinhas inline. Coordinator + candidate only.
|
||||
id: 'stateDeputies',
|
||||
label: municipalityColumnLabels.stateDeputies,
|
||||
head: (
|
||||
<CampaignTableHead description={municipalityColumnDescriptions.stateDeputies}>
|
||||
{municipalityColumnLabels.stateDeputies}
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
cell: (municipality) => (
|
||||
<MunicipalityStateDeputyRelationCell
|
||||
municipalityId={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
stateDeputyIDs={municipality.stateDeputyIDs}
|
||||
options={stateDeputyOptions}
|
||||
commitAction={stateDeputyCommitAction}
|
||||
createAction={stateDeputyCreateAction}
|
||||
editorVariant="popover"
|
||||
/>
|
||||
),
|
||||
},
|
||||
] satisfies Array<MunicipalityColumn>)
|
||||
: []),
|
||||
{
|
||||
// Right after "Assessores": the two relations of "who" in the
|
||||
// município sit together, and both are editable by all staff (the
|
||||
|
|
@ -453,7 +497,11 @@ const municipalityListColumns = ({
|
|||
|
||||
export const municipalityListPickerColumns = ({
|
||||
isStaffView,
|
||||
}: Pick<MunicipalityListProps, 'isStaffView'>): CampaignColumnPickerColumn[] => {
|
||||
isCampaignUnrestricted,
|
||||
}: Pick<
|
||||
MunicipalityListProps,
|
||||
'isStaffView' | 'isCampaignUnrestricted'
|
||||
>): CampaignColumnPickerColumn[] => {
|
||||
const base: CampaignColumnPickerColumn[] = [
|
||||
{ id: 'name', label: municipalityColumnLabels.name, mandatory: true },
|
||||
{ id: 'region', label: municipalityColumnLabels.region },
|
||||
|
|
@ -467,6 +515,9 @@ export const municipalityListPickerColumns = ({
|
|||
{ id: 'level', label: municipalityColumnLabels.level },
|
||||
{ id: 'advisors', label: municipalityColumnLabels.advisors },
|
||||
{ id: 'leaderships', label: municipalityColumnLabels.leaderships },
|
||||
...(isCampaignUnrestricted
|
||||
? [{ id: 'stateDeputies', label: municipalityColumnLabels.stateDeputies }]
|
||||
: []),
|
||||
{ id: 'trend', label: municipalityColumnLabels.trend },
|
||||
{ id: 'expectedVotes', label: municipalityColumnLabels.expectedVotes },
|
||||
{ id: 'lastSignal', label: municipalityColumnLabels.lastSignal },
|
||||
|
|
@ -482,8 +533,12 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
|
|||
leadershipNamesById,
|
||||
isStaffView,
|
||||
isCoordinator,
|
||||
isCampaignUnrestricted,
|
||||
advisorOptions,
|
||||
leadershipOptions,
|
||||
stateDeputyOptions,
|
||||
stateDeputyCommitAction,
|
||||
stateDeputyCreateAction,
|
||||
state,
|
||||
} = props
|
||||
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
|
||||
|
|
@ -505,9 +560,13 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
|
|||
leadershipNamesById={leadershipNamesById}
|
||||
isStaffView={isStaffView}
|
||||
isCoordinator={isCoordinator}
|
||||
isCampaignUnrestricted={isCampaignUnrestricted}
|
||||
canMoveEngagementLevel={props.canMoveEngagementLevel}
|
||||
advisorOptions={advisorOptions}
|
||||
leadershipOptions={leadershipOptions}
|
||||
stateDeputyOptions={stateDeputyOptions}
|
||||
stateDeputyCommitAction={stateDeputyCommitAction}
|
||||
stateDeputyCreateAction={stateDeputyCreateAction}
|
||||
signalFormAction={props.signalFormAction}
|
||||
emptySlot={<MunicipalityListEmptyState state={state} />}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,12 @@ import { MunicipalityListTrendControl } from '@/components/campaign/municipality
|
|||
import { MunicipalityPriorityIndicator } from '@/components/campaign/municipality/MunicipalityPriorityIndicator'
|
||||
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
|
||||
import { TerritoryLink } from '@/components/campaign/municipality/TerritoryLink'
|
||||
import {
|
||||
MunicipalityStateDeputyRelationCell,
|
||||
type MunicipalityStateDeputyCreateAction,
|
||||
} from '@/components/campaign/shared/MunicipalityStateDeputyRelationCell'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
|
||||
import { municipalityGeographyParts } from '@/utilities/municipality/municipalityLabels'
|
||||
import { municipalityColumnLabels } from '@/utilities/municipality/municipalityListUrl'
|
||||
import type {
|
||||
|
|
@ -46,10 +51,14 @@ export type MunicipalityListMobileCardsProps = {
|
|||
leadershipNamesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
|
||||
isStaffView: boolean
|
||||
isCoordinator: boolean
|
||||
isCampaignUnrestricted: boolean
|
||||
canMoveEngagementLevel: boolean
|
||||
advisorOptions: EligibleAdvisorOption[]
|
||||
/** B155 — every leadership the actor may add, for the Lideranças sheet. */
|
||||
leadershipOptions: EligibleLeadershipOption[]
|
||||
stateDeputyOptions: StateDeputyRelationOption[]
|
||||
stateDeputyCommitAction: MunicipalityStaffFormAction
|
||||
stateDeputyCreateAction: MunicipalityStateDeputyCreateAction
|
||||
signalFormAction: MunicipalityStaffFormAction
|
||||
emptySlot: ReactNode
|
||||
}
|
||||
|
|
@ -64,9 +73,13 @@ export const MunicipalityListMobileCards = ({
|
|||
leadershipNamesById,
|
||||
isStaffView,
|
||||
isCoordinator,
|
||||
isCampaignUnrestricted,
|
||||
canMoveEngagementLevel,
|
||||
advisorOptions,
|
||||
leadershipOptions,
|
||||
stateDeputyOptions,
|
||||
stateDeputyCommitAction,
|
||||
stateDeputyCreateAction,
|
||||
signalFormAction,
|
||||
emptySlot,
|
||||
}: MunicipalityListMobileCardsProps) => (
|
||||
|
|
@ -217,6 +230,22 @@ export const MunicipalityListMobileCards = ({
|
|||
/>
|
||||
</dd>
|
||||
</div>
|
||||
{isCampaignUnrestricted ? (
|
||||
<div className="col-span-2">
|
||||
<dt className="text-muted-foreground">Dobradinhas</dt>
|
||||
<dd>
|
||||
<MunicipalityStateDeputyRelationCell
|
||||
municipalityId={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
stateDeputyIDs={municipality.stateDeputyIDs}
|
||||
options={stateDeputyOptions}
|
||||
commitAction={stateDeputyCommitAction}
|
||||
createAction={stateDeputyCreateAction}
|
||||
editorVariant="sheet"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
) : null}
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,281 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useMemo, type ReactElement } from 'react'
|
||||
|
||||
import type { MunicipalityStateDeputyCreateResult } from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||||
import {
|
||||
RelationChipCell,
|
||||
type RelationChip,
|
||||
type RelationChipCellCopy,
|
||||
type RelationSearchHit,
|
||||
} from '@/components/campaign/shared/RelationChipCell'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { stateDeputyDisplayName } from '@/lib/stateDeputyNameParty'
|
||||
import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
|
||||
import { campaignUserInitials } from '@/utilities/campaignUserProfile'
|
||||
|
||||
const MAX_VISIBLE_AVATARS = 3
|
||||
|
||||
/** "Cicrano (PCdoB)" → "C" — parenthesized party never leaks into the initials. */
|
||||
const deputyInitials = (label: string): string => {
|
||||
const withoutParty = label.replace(/\([^()]*\)/g, '').trim()
|
||||
return campaignUserInitials(withoutParty || label)
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed-cell display of the "Dobradinhas" column (B157): the same avatar
|
||||
* stack as the advisors column — overlapping initials circles, hover reads the
|
||||
* names — with "—" for an empty município. The detail ("Fulano (PT)") lives in
|
||||
* the trigger tooltip and in the editor's removable chips, which link to the
|
||||
* ficha (same contract as advisors: no links inside the closed cell).
|
||||
*/
|
||||
const StateDeputyAvatarStack = ({ chips }: { chips: RelationChip[] }) => {
|
||||
if (chips.length === 0) return <span className="text-sm text-muted-foreground">—</span>
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex -space-x-2">
|
||||
{chips.slice(0, MAX_VISIBLE_AVATARS).map((chip) => (
|
||||
<Avatar key={chip.key} className="size-8 border-2 border-background">
|
||||
<AvatarFallback>{deputyInitials(chip.label)}</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One "Nome (Partido)" per line — the hover read of the closed cell. */
|
||||
const formatStateDeputyNamesTooltip = (chips: RelationChip[]): ReactElement | null =>
|
||||
chips.length === 0 ? null : (
|
||||
<div className="flex flex-col">
|
||||
{chips.map((chip) => (
|
||||
<span key={chip.key}>{chip.label}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* 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 `LeadershipStateDeputyRelationCell`).
|
||||
*/
|
||||
const normalizedLabelsByOptions = new WeakMap<
|
||||
readonly StateDeputyRelationOption[],
|
||||
Map<number, string>
|
||||
>()
|
||||
|
||||
const normalizedOptionLabels = (
|
||||
options: readonly StateDeputyRelationOption[],
|
||||
): Map<number, string> => {
|
||||
const cached = normalizedLabelsByOptions.get(options)
|
||||
if (cached) return cached
|
||||
|
||||
// Name AND party are searchable ("PT" finds "Fulano (PT)" — B157 aceite).
|
||||
const normalized = new Map(
|
||||
options.map(
|
||||
(option) =>
|
||||
[option.id, normalizeSearchPhrase(`${option.plainName} ${option.party ?? ''}`)] as const,
|
||||
),
|
||||
)
|
||||
normalizedLabelsByOptions.set(options, normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
/** Same WeakMap sharing for the id→option lookup the chips are built from. */
|
||||
const optionByIdByOptions = new WeakMap<
|
||||
readonly StateDeputyRelationOption[],
|
||||
Map<number, StateDeputyRelationOption>
|
||||
>()
|
||||
|
||||
const optionByIdFor = (
|
||||
options: readonly StateDeputyRelationOption[],
|
||||
): Map<number, StateDeputyRelationOption> => {
|
||||
const cached = optionByIdByOptions.get(options)
|
||||
if (cached) return cached
|
||||
const byId = new Map(options.map((option) => [option.id, option]))
|
||||
optionByIdByOptions.set(options, byId)
|
||||
return byId
|
||||
}
|
||||
|
||||
type MunicipalityStateDeputyRelationCellProps = {
|
||||
municipalityId: number
|
||||
municipalityName: string
|
||||
stateDeputyIDs: number[]
|
||||
options: StateDeputyRelationOption[]
|
||||
commitAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
createAction: MunicipalityStateDeputyCreateAction
|
||||
editorVariant: CampaignCellEditOverlayVariant
|
||||
}
|
||||
|
||||
/**
|
||||
* The inline-create form action as the cell consumes it — the success state
|
||||
* carries the created deputy so the cell can swap its optimistic chip without
|
||||
* waiting for the RSC refresh. Exported once so the list and the mobile cards
|
||||
* share the same type instead of re-declaring it.
|
||||
*/
|
||||
export type MunicipalityStateDeputyCreateAction = (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<
|
||||
CampaignFormActionState & { stateDeputy?: MunicipalityStateDeputyCreateResult['stateDeputy'] }
|
||||
>
|
||||
|
||||
const CREATE_LABEL = (rawName: string) => `Criar dobradinha “${rawName}”`
|
||||
|
||||
const chipOf = (option: StateDeputyRelationOption): RelationChip => ({
|
||||
key: String(option.id),
|
||||
// `option.name` already folds the party (the loader's single spelling).
|
||||
label: option.name,
|
||||
href: `/campanha/dobradinhas/${option.slug}`,
|
||||
ids: [option.id],
|
||||
})
|
||||
|
||||
/**
|
||||
* B157 — the "Dobradinhas" column of `/campanha/municipios`: a thin domain
|
||||
* shell over `RelationChipCell` in trigger mode. The closed cell is the
|
||||
* advisors-style avatar stack (hover reads "Nome (Partido)"); the editor opens
|
||||
* in a Popover (desktop) / Drawer (mobile) with removable chips, a combobox
|
||||
* that searches name AND party, and inline create ("Criar dobradinha 'texto'",
|
||||
* `Nome (PARTIDO)` syntax). Every write goes through the same actions the
|
||||
* `/campanha/dobradinhas` list already uses — nothing new on the server side
|
||||
* for toggles.
|
||||
*/
|
||||
export const MunicipalityStateDeputyRelationCell = ({
|
||||
municipalityId,
|
||||
municipalityName,
|
||||
stateDeputyIDs,
|
||||
options,
|
||||
commitAction,
|
||||
createAction,
|
||||
editorVariant,
|
||||
}: MunicipalityStateDeputyRelationCellProps) => {
|
||||
// Same WeakMap sharing as the normalized labels above: the RSC builds ONE
|
||||
// options array for every row, so the id→option map is computed once per
|
||||
// request instead of per row (25 × catalog-size inserts otherwise).
|
||||
const optionById = optionByIdFor(options)
|
||||
|
||||
const buildChips = useCallback(
|
||||
(ids: number[]): RelationChip[] => {
|
||||
const chips: RelationChip[] = []
|
||||
for (const id of ids) {
|
||||
const option = optionById.get(id)
|
||||
if (!option) continue
|
||||
chips.push(chipOf(option))
|
||||
}
|
||||
return chips
|
||||
},
|
||||
[optionById],
|
||||
)
|
||||
|
||||
const searchHits = useCallback(
|
||||
(query: string, assignedIds: ReadonlySet<number>): RelationSearchHit[] => {
|
||||
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.name, ids: [option.id] })
|
||||
}
|
||||
return hits
|
||||
},
|
||||
[options],
|
||||
)
|
||||
|
||||
const buildFormData = useCallback(
|
||||
(changedIds: number[], assigned: boolean) => {
|
||||
const formData = new FormData()
|
||||
formData.set('municipalityId', String(municipalityId))
|
||||
formData.set('stateDeputyId', String(changedIds[0]))
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
return formData
|
||||
},
|
||||
[municipalityId],
|
||||
)
|
||||
|
||||
const buildCreateFormData = useCallback(
|
||||
(rawName: string) => {
|
||||
const formData = new FormData()
|
||||
formData.set('municipalityId', String(municipalityId))
|
||||
formData.set('rawName', rawName)
|
||||
return formData
|
||||
},
|
||||
[municipalityId],
|
||||
)
|
||||
|
||||
/** Maps the action's `stateDeputy` payload to the cell's `createdChip`. */
|
||||
const createActionWithChip = useCallback(
|
||||
async (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState & { createdChip?: RelationChip }> => {
|
||||
const result = await createAction(state, formData)
|
||||
if (result.status !== 'success' || !result.stateDeputy) return result
|
||||
const created = result.stateDeputy
|
||||
return {
|
||||
...result,
|
||||
createdChip: {
|
||||
key: String(created.id),
|
||||
label: stateDeputyDisplayName(created.name, created.party),
|
||||
href: `/campanha/dobradinhas/${created.slug}`,
|
||||
ids: [created.id],
|
||||
},
|
||||
}
|
||||
},
|
||||
[createAction],
|
||||
)
|
||||
|
||||
const copy = useMemo<RelationChipCellCopy>(
|
||||
() => ({
|
||||
searchPlaceholder: 'Buscar dobradinha…',
|
||||
searchLabel: 'Buscar dobradinha',
|
||||
suggestionsLabel: 'Sugestões de dobradinhas',
|
||||
emptyDrawerMessage: 'Nenhuma dobradinha vinculada.',
|
||||
savingMessage: 'Salvando dobradinhas.',
|
||||
savedMessage: 'Dobradinhas salvas.',
|
||||
removedMessage: (count) =>
|
||||
count === 1 ? 'Dobradinha removida.' : `${count} dobradinhas removidas.`,
|
||||
createHintMessage: 'Nenhum resultado — opção de criar dobradinha disponível.',
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<RelationChipCell
|
||||
ownerId={municipalityId}
|
||||
ownerName={municipalityName}
|
||||
ids={stateDeputyIDs}
|
||||
buildChips={buildChips}
|
||||
searchHits={searchHits}
|
||||
buildFormData={buildFormData}
|
||||
commitAction={commitAction}
|
||||
drawerTitle="Dobradinhas do município"
|
||||
// The advisor twin's contract: the names ride in the trigger's label so
|
||||
// a screen reader reads WHO is assigned without opening the editor (the
|
||||
// hover tooltip is pointer-only).
|
||||
triggerLabel={(chips) =>
|
||||
`Editar dobradinhas em ${municipalityName}${chips.length ? ` — ${chips.map((chip) => chip.label).join(', ')}` : ''}`
|
||||
}
|
||||
updateErrorMessage="Não foi possível atualizar as dobradinhas. Tente novamente."
|
||||
copy={copy}
|
||||
measureOverflow={false}
|
||||
trigger={(chips) => <StateDeputyAvatarStack chips={chips} />}
|
||||
triggerTooltip={formatStateDeputyNamesTooltip}
|
||||
editorVariant={editorVariant}
|
||||
createAction={createActionWithChip}
|
||||
buildCreateFormData={buildCreateFormData}
|
||||
createLabel={CREATE_LABEL}
|
||||
createErrorMessage="Não foi possível criar a dobradinha. Tente novamente."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { PlusIcon, XIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
useCallback,
|
||||
|
|
@ -15,13 +15,17 @@ import {
|
|||
} from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||||
import {
|
||||
CampaignCellEditOverlay,
|
||||
type CampaignCellEditOverlayVariant,
|
||||
} 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 { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
|
||||
|
||||
|
|
@ -34,6 +38,9 @@ const resultsMessage = (count: number): string => {
|
|||
return count === 1 ? '1 resultado.' : `${count} resultados.`
|
||||
}
|
||||
|
||||
/** B157 — the "Criar …" suggestion's listbox key (no real id yet). */
|
||||
const CREATE_HIT_KEY = '__create__'
|
||||
|
||||
const withDelta = (
|
||||
base: readonly number[],
|
||||
changedIds: readonly number[],
|
||||
|
|
@ -47,7 +54,7 @@ const withDelta = (
|
|||
return [...next]
|
||||
}
|
||||
|
||||
type SuggestionSurface = 'inline' | 'drawer'
|
||||
type SuggestionSurface = 'inline' | 'drawer' | 'popover'
|
||||
|
||||
export type RelationChip = {
|
||||
key: string
|
||||
|
|
@ -80,6 +87,8 @@ export type RelationChipCellCopy = {
|
|||
savingMessage: string
|
||||
savedMessage: string
|
||||
removedMessage: (count: number) => string
|
||||
/** B157 — announced while the only suggestion is the "Criar …" option. */
|
||||
createHintMessage?: string
|
||||
/** Only invoked when `minItems` is actually configured. */
|
||||
floorMessage?: (min: number) => string
|
||||
/** Only invoked when `maxItems` is actually configured. */
|
||||
|
|
@ -113,11 +122,41 @@ type RelationChipCellProps = {
|
|||
) => Promise<CampaignFormActionState>
|
||||
drawerTitle: string
|
||||
/** Full aria-label for the coarse-pointer, cell-wide Drawer trigger. */
|
||||
triggerLabel: string
|
||||
triggerLabel: string | ((chips: RelationChip[]) => string)
|
||||
updateErrorMessage: string
|
||||
copy: RelationChipCellCopy
|
||||
/** Clamp rest chips to 3 rows + "Ver mais…". Default true; pass false when few chips are expected. */
|
||||
measureOverflow?: boolean
|
||||
|
||||
// Trigger mode (B157) ───────────────────────────────────────────────────────
|
||||
/**
|
||||
* Custom closed-cell display (e.g. an avatar stack). When present, the cell
|
||||
* renders this node as the edit trigger and the editor body — assigned chips
|
||||
* + search + create — opens in a `CampaignCellEditOverlay` of
|
||||
* `editorVariant`, instead of the inline row editor / coarse Drawer split.
|
||||
* `chips` includes the optimistic state (and any pending/created chip).
|
||||
*/
|
||||
trigger?: (chips: RelationChip[], isPending: boolean) => ReactNode
|
||||
/** The overlay variant in trigger mode — the call site knows its viewport. */
|
||||
editorVariant?: CampaignCellEditOverlayVariant
|
||||
/** Popover-only hover/focus read without opening the editor. */
|
||||
triggerTooltip?: (chips: RelationChip[]) => ReactNode
|
||||
|
||||
// Inline create (B157) ─────────────────────────────────────────────────────
|
||||
/**
|
||||
* When set (with `buildCreateFormData`), a search with no hits and a
|
||||
* plausible name offers "Criar …" as the only suggestion: the chip goes up
|
||||
* under a pending marker, the action creates + assigns server-side, and the
|
||||
* returned `createdChip` swaps in optimistically until the RSC refresh
|
||||
* brings the real id into `ids`.
|
||||
*/
|
||||
createAction?: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState & { createdChip?: RelationChip }>
|
||||
buildCreateFormData?: (rawName: string) => FormData
|
||||
createLabel?: (rawName: string) => string
|
||||
createErrorMessage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -147,9 +186,18 @@ export const RelationChipCell = ({
|
|||
updateErrorMessage,
|
||||
copy,
|
||||
measureOverflow = true,
|
||||
trigger,
|
||||
editorVariant = 'popover',
|
||||
triggerTooltip,
|
||||
createAction,
|
||||
buildCreateFormData,
|
||||
createLabel,
|
||||
createErrorMessage = updateErrorMessage,
|
||||
}: RelationChipCellProps) => {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
/** B157 — trigger mode's overlay open state (the inline `open` stays inert there). */
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [optimistic, setOptimistic] = useState<number[] | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
|
@ -161,6 +209,10 @@ export const RelationChipCell = ({
|
|||
const [feedback, setFeedback] = useState<
|
||||
{ kind: 'saved' } | { kind: 'error'; message: string } | null
|
||||
>(null)
|
||||
/** B157 — the optimistic chip of an in-flight inline create (no remove affordance). */
|
||||
const [pendingCreate, setPendingCreate] = useState<RelationChip | null>(null)
|
||||
/** B157 — chips the create action already returned, until `ids` catches up. */
|
||||
const [createdChips, setCreatedChips] = useState<RelationChip[]>([])
|
||||
/** 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. */
|
||||
|
|
@ -177,6 +229,11 @@ export const RelationChipCell = ({
|
|||
[baseId],
|
||||
)
|
||||
|
||||
/** B157 — trigger mode replaces the inline row editor with a custom closed display + overlay. */
|
||||
const isTriggerMode = trigger !== undefined
|
||||
const canCreate = createAction !== undefined && buildCreateFormData !== undefined
|
||||
const effectiveMeasureOverflow = measureOverflow && !isTriggerMode
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blurCloseTimeoutRef.current != null) {
|
||||
|
|
@ -209,28 +266,92 @@ export const RelationChipCell = ({
|
|||
}, [effectiveIds])
|
||||
|
||||
const assignedIds = useMemo(() => new Set(effectiveIds), [effectiveIds])
|
||||
const chips = useMemo(() => buildChips(effectiveIds), [effectiveIds, buildChips])
|
||||
const chips = useMemo(() => {
|
||||
const base = buildChips(effectiveIds)
|
||||
// B157 — chips the create action already returned render from their own
|
||||
// data only until the catalog knows them: `base` resolves them as soon as
|
||||
// the RSC refresh brings the option, so the copy drops out then — without
|
||||
// depending on `ids` arriving (a refresh superseded by a removal must not
|
||||
// leave a duplicate copy behind, and the `effectiveIds` check keeps a chip
|
||||
// the user just removed from coming back).
|
||||
const baseIds = new Set(base.map((chip) => chip.ids[0]))
|
||||
const created = createdChips.filter(
|
||||
(chip) => !baseIds.has(chip.ids[0]) && effectiveIds.includes(chip.ids[0]),
|
||||
)
|
||||
const pending = pendingCreate ? [pendingCreate] : []
|
||||
return [...base, ...created, ...pending]
|
||||
}, [buildChips, effectiveIds, createdChips, pendingCreate])
|
||||
|
||||
// B157 — hygiene: once the server id arrives, the created chip's copy is
|
||||
// redundant state. Bails on no-op so cells that never create (B36/B37) do
|
||||
// not pay a fresh-array render pass on every mount / refresh.
|
||||
useEffect(() => {
|
||||
setCreatedChips((current) => {
|
||||
if (current.length === 0) return current
|
||||
const next = current.filter((chip) => !ids.includes(chip.ids[0]))
|
||||
return next.length === current.length ? current : next
|
||||
})
|
||||
}, [ids])
|
||||
|
||||
/** B157 — the trigger's closed display reads the same optimistic chips. */
|
||||
const triggerNode = trigger?.(chips, isPending)
|
||||
const triggerTooltipNode = triggerTooltip ? triggerTooltip(chips) : undefined
|
||||
const resolvedTriggerLabel =
|
||||
typeof triggerLabel === 'function' ? triggerLabel(chips) : triggerLabel
|
||||
|
||||
const searching = query.trim().length > 0
|
||||
const trimmedQuery = query.trim()
|
||||
/**
|
||||
* 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 inlineSuggesting = searching && open && !drawerOpen && !isTriggerMode
|
||||
const drawerSuggesting = searching && drawerOpen
|
||||
const suggesting = inlineSuggesting || drawerSuggesting
|
||||
const popoverSuggesting = searching && editorOpen && isTriggerMode
|
||||
const suggesting = inlineSuggesting || drawerSuggesting || popoverSuggesting
|
||||
const hits = useMemo(
|
||||
() => (suggesting ? searchHits(query, assignedIds) : []),
|
||||
[suggesting, query, searchHits, assignedIds],
|
||||
)
|
||||
|
||||
const hitsKey = hits.map((hit) => hit.key).join('|')
|
||||
/**
|
||||
* B157 — when the search matches nothing and a create is wired up, the ONLY
|
||||
* suggestion is "Criar …" (B154 pattern). It rides the same ARIA listbox and
|
||||
* the same keyboard contract as the hits: arrows reach it, Enter fires it.
|
||||
* Suppressed when the query EXACTLY matches an already-assigned chip's
|
||||
* label (normalized): the search only offers addable items, so an exact
|
||||
* assigned name would otherwise offer to mint a duplicate of it — while a
|
||||
* mere prefix ("Fulano" when "Fulano Silva (PT)" is assigned) still allows
|
||||
* creating a genuinely new name. The `chips.some` runs last so cells
|
||||
* without create wiring (B36/B37) never pay its per-chip normalization.
|
||||
*/
|
||||
const normalizedCreateQuery = normalizeSearchPhrase(trimmedQuery)
|
||||
const createHit: RelationSearchHit | null =
|
||||
canCreate &&
|
||||
suggesting &&
|
||||
hits.length === 0 &&
|
||||
trimmedQuery.length >= 2 &&
|
||||
trimmedQuery.length <= 160 &&
|
||||
!chips.some((chip) => normalizeSearchPhrase(chip.label) === normalizedCreateQuery)
|
||||
? {
|
||||
key: CREATE_HIT_KEY,
|
||||
label: createLabel ? createLabel(trimmedQuery) : `Criar “${trimmedQuery}”`,
|
||||
ids: [],
|
||||
}
|
||||
: null
|
||||
const listOptions = createHit ? [...hits, createHit] : hits
|
||||
|
||||
const optionsKey = listOptions.map((option) => option.key).join('|')
|
||||
useEffect(() => {
|
||||
setActiveIndex(0)
|
||||
}, [hitsKey])
|
||||
}, [optionsKey])
|
||||
|
||||
const activeSurface: SuggestionSurface = drawerOpen ? 'drawer' : 'inline'
|
||||
const activeSurface: SuggestionSurface = isTriggerMode
|
||||
? 'popover'
|
||||
: drawerOpen
|
||||
? 'drawer'
|
||||
: 'inline'
|
||||
useEffect(() => {
|
||||
if (!activeFromKeyboard.current) return
|
||||
document.getElementById(optionId(activeSurface, activeIndex))?.scrollIntoView({
|
||||
|
|
@ -253,12 +374,12 @@ export const RelationChipCell = ({
|
|||
// 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
|
||||
if (!effectiveMeasureOverflow) return
|
||||
invalidateMeasurement()
|
||||
}, [chipsKey, measureOverflow, invalidateMeasurement])
|
||||
}, [chipsKey, effectiveMeasureOverflow, invalidateMeasurement])
|
||||
|
||||
useEffect(() => {
|
||||
if (!measureOverflow) return
|
||||
if (!effectiveMeasureOverflow) return
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
// Seeded here so the observer's initial callback isn't read as a resize.
|
||||
|
|
@ -270,7 +391,7 @@ export const RelationChipCell = ({
|
|||
})
|
||||
observer.observe(row)
|
||||
return () => observer.disconnect()
|
||||
}, [measureOverflow, invalidateMeasurement])
|
||||
}, [effectiveMeasureOverflow, invalidateMeasurement])
|
||||
|
||||
/**
|
||||
* Runs on the frames where every chip is in the DOM, so the packing of the
|
||||
|
|
@ -278,7 +399,7 @@ export const RelationChipCell = ({
|
|||
* read from the real layout instead of estimated from label lengths.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!measureOverflow) return
|
||||
if (!effectiveMeasureOverflow) return
|
||||
if (visibleChipCount !== null) return
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
|
|
@ -316,7 +437,7 @@ export const RelationChipCell = ({
|
|||
}
|
||||
|
||||
setVisibleChipCount(fitting)
|
||||
}, [measureOverflow, measureToken, visibleChipCount])
|
||||
}, [effectiveMeasureOverflow, measureToken, visibleChipCount])
|
||||
|
||||
/**
|
||||
* Every delta is applied functionally, forward and back: two chips toggled in
|
||||
|
|
@ -408,6 +529,49 @@ export const RelationChipCell = ({
|
|||
commit(hit.ids, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* B157 — inline create ("Criar …" when the search matches nothing). The chip
|
||||
* goes up immediately under a pending marker (no remove affordance — the
|
||||
* create cannot be cancelled mid-flight, same contract as B154); the action
|
||||
* creates + assigns server-side, and the returned `createdChip` swaps in
|
||||
* optimistically. A failed create reverts the chip and shows the error.
|
||||
*/
|
||||
const create = (rawName: string) => {
|
||||
if (!createAction || !buildCreateFormData || pendingCreate) return
|
||||
setQuery('')
|
||||
setFeedback(null)
|
||||
setPendingCreate({ key: `create-${rawName}`, label: rawName, ids: [] })
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createAction({}, buildCreateFormData(rawName))
|
||||
if (result.status !== 'success' || !result.createdChip) {
|
||||
setPendingCreate(null)
|
||||
const message = firstFormActionMessage(result) ?? createErrorMessage
|
||||
setFeedback({ kind: 'error', message })
|
||||
toast.error(message)
|
||||
return
|
||||
}
|
||||
const created = result.createdChip
|
||||
setPendingCreate(null)
|
||||
setCreatedChips((current) => [...current, created])
|
||||
// Adopt the real id optimistically: the chip becomes removable right
|
||||
// away, and the reconcile effect above clears once the RSC refresh
|
||||
// brings the id into `ids`.
|
||||
setOptimistic((current) => withDelta(current ?? latestIds.current, created.ids, true))
|
||||
})
|
||||
}
|
||||
|
||||
/** One pick path for hits AND the "Criar …" option (same ARIA listbox). */
|
||||
const pickOption = (index: number) => {
|
||||
const option = listOptions[index]
|
||||
if (!option) return
|
||||
if (option === createHit) {
|
||||
create(trimmedQuery)
|
||||
return
|
||||
}
|
||||
pickHit(option)
|
||||
}
|
||||
|
||||
const closeSuggestions = () => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
|
|
@ -431,29 +595,28 @@ export const RelationChipCell = ({
|
|||
*/
|
||||
const onSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
if (hits.length === 0) return
|
||||
if (listOptions.length === 0) return
|
||||
event.preventDefault()
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||||
activeFromKeyboard.current = true
|
||||
setActiveIndex((current) => (current + delta + hits.length) % hits.length)
|
||||
setActiveIndex((current) => (current + delta + listOptions.length) % listOptions.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Home' || event.key === 'End') {
|
||||
if (hits.length === 0) return
|
||||
if (listOptions.length === 0) return
|
||||
event.preventDefault()
|
||||
activeFromKeyboard.current = true
|
||||
setActiveIndex(event.key === 'Home' ? 0 : hits.length - 1)
|
||||
setActiveIndex(event.key === 'Home' ? 0 : listOptions.length - 1)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
const hit = hits[activeIndex]
|
||||
if (!hit) return
|
||||
if (listOptions.length === 0) return
|
||||
event.preventDefault()
|
||||
pickHit(hit)
|
||||
pickOption(activeIndex)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
if (drawerOpen) {
|
||||
if (drawerOpen || isTriggerMode) {
|
||||
setQuery('')
|
||||
return
|
||||
}
|
||||
|
|
@ -463,12 +626,12 @@ export const RelationChipCell = ({
|
|||
}
|
||||
|
||||
// Editing needs every chip reachable, so focusing the cell always expands it.
|
||||
const showAllChips = !measureOverflow || expanded || open
|
||||
const measuring = measureOverflow && visibleChipCount === null
|
||||
const showAllChips = !effectiveMeasureOverflow || expanded || open
|
||||
const measuring = effectiveMeasureOverflow && visibleChipCount === null
|
||||
const hasHiddenChips =
|
||||
measureOverflow && visibleChipCount !== null && visibleChipCount < chips.length
|
||||
effectiveMeasureOverflow && visibleChipCount !== null && visibleChipCount < chips.length
|
||||
const visibleChips =
|
||||
!measureOverflow || showAllChips || measuring || visibleChipCount === null
|
||||
!effectiveMeasureOverflow || showAllChips || measuring || visibleChipCount === null
|
||||
? chips
|
||||
: chips.slice(0, visibleChipCount)
|
||||
/**
|
||||
|
|
@ -489,7 +652,9 @@ export const RelationChipCell = ({
|
|||
: feedback?.kind === 'error'
|
||||
? feedback.message
|
||||
: suggesting
|
||||
? resultsMessage(hits.length)
|
||||
? createHit
|
||||
? (copy.createHintMessage ?? 'Nenhum resultado — opção de criar disponível.')
|
||||
: resultsMessage(hits.length)
|
||||
: feedback?.kind === 'saved'
|
||||
? copy.savedMessage
|
||||
: ''
|
||||
|
|
@ -547,6 +712,9 @@ export const RelationChipCell = ({
|
|||
*/
|
||||
const inlineChip = (chip: RelationChip) => {
|
||||
const floorReason = removalFloorReason(chip)
|
||||
// A pending-create chip has no remove affordance: the server-side create
|
||||
// cannot be cancelled mid-flight, so removal waits for the real id (B154).
|
||||
const isPendingCreate = chip.key === pendingCreate?.key
|
||||
return (
|
||||
<span
|
||||
key={chip.key}
|
||||
|
|
@ -564,34 +732,37 @@ export const RelationChipCell = ({
|
|||
{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>
|
||||
{isPendingCreate ? null : (
|
||||
<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 isCreateHit = hit.key === CREATE_HIT_KEY
|
||||
const capped = !isCreateHit && additionCapReason(hit.ids) !== null
|
||||
const active = index === activeIndex
|
||||
return (
|
||||
<button
|
||||
|
|
@ -600,7 +771,6 @@ export const RelationChipCell = ({
|
|||
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',
|
||||
|
|
@ -612,8 +782,9 @@ export const RelationChipCell = ({
|
|||
activeFromKeyboard.current = false
|
||||
setActiveIndex(index)
|
||||
}}
|
||||
onClick={() => pickHit(hit)}
|
||||
onClick={() => pickOption(index)}
|
||||
>
|
||||
{isCreateHit ? <PlusIcon className="mt-0.5 size-4 shrink-0" aria-hidden="true" /> : null}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{hit.label}</span>
|
||||
{hit.description ? (
|
||||
|
|
@ -630,20 +801,132 @@ export const RelationChipCell = ({
|
|||
/**
|
||||
* 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.
|
||||
* reader would count it as a selectable option. The "Criar …" option rides
|
||||
* INSIDE the listbox: it is actionable, and the keyboard contract reaches it.
|
||||
*/
|
||||
const suggestionList = (surface: SuggestionSurface) => (
|
||||
<>
|
||||
<div role="listbox" id={listboxId(surface)} aria-label={copy.suggestionsLabel}>
|
||||
{hits.map((hit, index) => suggestionOption(hit, index, surface))}
|
||||
{listOptions.map((option, index) => suggestionOption(option, index, surface))}
|
||||
</div>
|
||||
{hits.length === 0 ? (
|
||||
{listOptions.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
|
||||
const activeOptionId = listOptions.length > 0 ? optionId(activeSurface, activeIndex) : undefined
|
||||
|
||||
/** One chip row of the editor list (Drawer or trigger-mode Popover). */
|
||||
const chipListItem = (chip: RelationChip) => {
|
||||
const floorReason = removalFloorReason(chip)
|
||||
const isPendingCreate = chip.key === pendingCreate?.key
|
||||
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>
|
||||
)}
|
||||
{isPendingCreate ? null : (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor body shared by the coarse Drawer and the trigger-mode overlay:
|
||||
* assigned chips (removable, linked) + the search combobox + suggestions.
|
||||
*/
|
||||
const editorBody = (surface: 'drawer' | 'popover', suggesting: boolean) => (
|
||||
<>
|
||||
{chips.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{copy.emptyDrawerMessage}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">{chips.map(chipListItem)}</ul>
|
||||
)}
|
||||
<Input
|
||||
value={query}
|
||||
autoFocus={surface === 'popover'}
|
||||
role="combobox"
|
||||
aria-expanded={suggesting}
|
||||
// Same as the inline input: the list is only rendered while searching,
|
||||
// so a permanent `aria-controls` would dangle.
|
||||
aria-controls={suggesting ? listboxId(surface) : undefined}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={suggesting ? 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(surface)}</div> : null}
|
||||
</>
|
||||
)
|
||||
|
||||
// B157 trigger mode: the closed cell is the custom display (e.g. an avatar
|
||||
// stack) and the editor opens in a `CampaignCellEditOverlay` of the call
|
||||
// site's variant — Popover on the desktop table, shared Drawer on the
|
||||
// mobile cards — same gesture contract as the advisors column (B27/B154).
|
||||
if (isTriggerMode) {
|
||||
return (
|
||||
<div ref={rootRef} aria-busy={isPending || undefined} className="relative min-w-56">
|
||||
<CampaignCellEditOverlay
|
||||
variant={editorVariant}
|
||||
open={editorOpen}
|
||||
onOpenChange={(next) => {
|
||||
setEditorOpen(next)
|
||||
// Guarded on the editor having actually been open: a closed overlay
|
||||
// can still report `onOpenChange(false)` from a page-wide dismiss.
|
||||
if (!next && editorOpen) closeSuggestions()
|
||||
}}
|
||||
title={drawerTitle}
|
||||
description={ownerName}
|
||||
trigger={triggerNode}
|
||||
triggerLabel={resolvedTriggerLabel}
|
||||
triggerBusy={isPending}
|
||||
statusMessage={statusMessage}
|
||||
tooltipContent={triggerTooltipNode}
|
||||
contentClassName="w-80 p-0"
|
||||
// The sheet body already carries `px-4 pt-3 pb-2` — zeroing the
|
||||
// horizontal padding here makes the shared body wrapper the single
|
||||
// padding owner for both surfaces (16 px in each).
|
||||
sheetBodyClassName="px-0"
|
||||
>
|
||||
<div className="flex flex-col gap-3 px-4 py-3">
|
||||
{editorBody('popover', popoverSuggesting)}
|
||||
</div>
|
||||
</CampaignCellEditOverlay>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -757,7 +1040,7 @@ export const RelationChipCell = ({
|
|||
title={drawerTitle}
|
||||
description={ownerName}
|
||||
trigger={null}
|
||||
triggerLabel={triggerLabel}
|
||||
triggerLabel={resolvedTriggerLabel}
|
||||
triggerClassName="absolute inset-0 hover:bg-transparent pointer-fine:hidden"
|
||||
triggerBusy={isPending}
|
||||
sheetBodyClassName="gap-3"
|
||||
|
|
@ -769,68 +1052,7 @@ export const RelationChipCell = ({
|
|||
* 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}
|
||||
// Same as the inline input: the drawer list is only rendered while
|
||||
// searching, so a permanent `aria-controls` would dangle.
|
||||
aria-controls={drawerSuggesting ? listboxId('drawer') : undefined}
|
||||
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}
|
||||
</>
|
||||
<>{editorBody('drawer', drawerSuggesting)}</>
|
||||
</CampaignCellEditOverlay>
|
||||
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
trimmedNullableText,
|
||||
trimmedOptionalText,
|
||||
} from '@/lib/schemas/primitives'
|
||||
import { parseStateDeputyNameParty } from '@/lib/stateDeputyNameParty'
|
||||
|
||||
export const stateDeputyCreateSchema = z.object({
|
||||
name: z.string().trim().min(2).max(160),
|
||||
|
|
@ -34,6 +35,13 @@ export const STATE_DEPUTY_STAFF_MESSAGE =
|
|||
/** 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.'
|
||||
|
||||
/**
|
||||
* B157 — thrown by `setCanonicalStateDeputySlug` when a name slugifies empty;
|
||||
* safelisted by the inline-create route (a "Criar …" query of only punctuation
|
||||
* must not collapse into the generic message).
|
||||
*/
|
||||
export const STATE_DEPUTY_NAME_REQUIRED_MESSAGE = 'Informe um nome com letras ou números.'
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
|
@ -57,8 +65,32 @@ export const STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES = [
|
|||
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
] as const
|
||||
|
||||
/**
|
||||
* B157 — inline create from the municípios list ("Criar dobradinha 'texto'"):
|
||||
* the raw search text is split by `parseStateDeputyNameParty`, so a trailing
|
||||
* `(PARTIDO)` becomes the party and the rest the name. The pipe re-validates
|
||||
* AFTER the split — a name that only parses to empty (e.g. "(PT)") is refused
|
||||
* instead of minting a deputy named "(PT)".
|
||||
*/
|
||||
export const municipalityStateDeputyCreateSchema = z
|
||||
.object({
|
||||
municipalityId: positiveRelationshipId,
|
||||
rawName: z.string().min(1).max(200),
|
||||
})
|
||||
.transform((input) => {
|
||||
const { name, party } = parseStateDeputyNameParty(input.rawName)
|
||||
return { municipalityId: input.municipalityId, name, party }
|
||||
})
|
||||
.refine((value) => value.name.length >= 2 && value.name.length <= 160, {
|
||||
message: 'O nome precisa ter entre 2 e 160 caracteres.',
|
||||
})
|
||||
.refine((value) => (value.party ?? '').length <= 32, {
|
||||
message: 'O partido pode ter no máximo 32 caracteres.',
|
||||
})
|
||||
|
||||
export type StateDeputyCreateInput = z.input<typeof stateDeputyCreateSchema>
|
||||
export type StateDeputyUpdateInput = z.input<typeof stateDeputyUpdateSchema>
|
||||
export type StateDeputyMunicipalitiesBatchInput = z.input<
|
||||
typeof stateDeputyMunicipalitiesBatchSchema
|
||||
>
|
||||
export type MunicipalityStateDeputyCreateInput = z.input<typeof municipalityStateDeputyCreateSchema>
|
||||
|
|
|
|||
38
src/lib/stateDeputyNameParty.ts
Normal file
38
src/lib/stateDeputyNameParty.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* B157 — inline create of a dobradinha from the municípios list combobox.
|
||||
*
|
||||
* The search text doubles as the create form: a trailing `(PARTIDO)` group is
|
||||
* extracted as the deputy's party, everything before it is the name. The rule
|
||||
* is deliberately narrow — ONE parenthesized group at the very end, with no
|
||||
* nesting — so "Fulano (PT)" → `{ name: 'Fulano', party: 'PT' }` and
|
||||
* "Fulano (PT) da Silva" (parens mid-string) stays a name with no party.
|
||||
* When the whole string is a single group, e.g. "(PT)", the name comes back
|
||||
* empty so the schema can refuse it instead of minting a deputy named "(PT)".
|
||||
*/
|
||||
export type StateDeputyNameParty = {
|
||||
name: string
|
||||
party: string | null
|
||||
}
|
||||
|
||||
const WHOLE_STRING_IS_A_GROUP = /^\(([^()]+)\)$/
|
||||
const TRAILING_GROUP = /^(.+?)\s*\(([^()]+)\)\s*$/
|
||||
|
||||
export const parseStateDeputyNameParty = (rawName: string): StateDeputyNameParty => {
|
||||
const trimmed = rawName.trim()
|
||||
|
||||
const wholeGroup = WHOLE_STRING_IS_A_GROUP.exec(trimmed)
|
||||
if (wholeGroup) return { name: '', party: wholeGroup[1].trim() }
|
||||
|
||||
const trailing = TRAILING_GROUP.exec(trimmed)
|
||||
if (!trailing) return { name: trimmed, party: null }
|
||||
|
||||
return { name: trailing[1].trim(), party: trailing[2].trim() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The display fold `"Fulano (PT)"` for a name + party pair — the single
|
||||
* spelling every dobradinha chip/suggestion uses (client-safe; the server
|
||||
* catalog already folds it into `StateDeputyRelationOption.name`).
|
||||
*/
|
||||
export const stateDeputyDisplayName = (name: string, party: string | null): string =>
|
||||
party ? `${name} (${party})` : name
|
||||
|
|
@ -654,7 +654,7 @@ export interface AllocationDecision {
|
|||
municipality: number | Municipality;
|
||||
patternId: string;
|
||||
outcome: 'aceita' | 'descarta' | 'movimento' | 'adiada';
|
||||
rationale: string;
|
||||
rationale?: string | null;
|
||||
alternativeReading?: string | null;
|
||||
/**
|
||||
* Somente os números e classificações usados no momento da decisão.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { ValidationError, type Payload } from 'payload'
|
||||
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { reloadStaffActor } from '@/utilities/campaignActionContext'
|
||||
|
|
@ -19,6 +19,24 @@ export type StaffEntityPolicy = {
|
|||
conflictMessage: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a unique-index violation into the policy's safe message, or
|
||||
* returns `null` when the error is something else. Two shapes cover the same
|
||||
* failure: the DB constraint the `conflictPattern` matches (an insert race),
|
||||
* and Payload's `ValidationError` — Payload validates `unique` fields BEFORE
|
||||
* the insert and throws a localized \"O campo a seguir está inválido: <field>\"
|
||||
* that the pattern can never match, which is how the normal duplicate case
|
||||
* surfaces. (After a zod gate, the only validation left on these create/update
|
||||
* paths IS the unique check, so a `ValidationError` here is a duplicate.)
|
||||
*/
|
||||
export const mapStaffEntityConflict = (error: unknown, policy: StaffEntityPolicy): Error | null => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (policy.conflictPattern.test(message) || error instanceof ValidationError) {
|
||||
return new Error(policy.conflictMessage)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const runStaffEntityMutation = async <Result>(
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
|
|
@ -30,10 +48,8 @@ export const runStaffEntityMutation = async <Result>(
|
|||
try {
|
||||
return await mutate(currentActor)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (policy.conflictPattern.test(message)) {
|
||||
throw new Error(policy.conflictMessage)
|
||||
}
|
||||
const conflict = mapStaffEntityConflict(error, policy)
|
||||
if (conflict) throw conflict
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ export type MunicipalityListColumnId =
|
|||
| 'level'
|
||||
| 'advisors'
|
||||
| 'leaderships'
|
||||
| 'stateDeputies'
|
||||
| 'trend'
|
||||
| 'expectedVotes'
|
||||
| 'lastSignal'
|
||||
|
|
@ -220,6 +221,8 @@ export const municipalityColumnDescriptions: Record<MunicipalityListColumnId, st
|
|||
level: campaignConceptOneLiner('nivel-de-envolvimento'),
|
||||
advisors: 'Assessor(es) responsável(is) pelo município — o coordenador atribui clicando aqui.',
|
||||
leaderships: 'Lideranças vinculadas ao município — a equipe adiciona e remove clicando aqui.',
|
||||
stateDeputies:
|
||||
'Deputados estaduais com quem a campanha dobra neste município — coordenação e candidato editam clicando aqui.',
|
||||
trend: 'Tendência política percebida pela equipe: favorável, neutra ou desfavorável.',
|
||||
expectedVotes: campaignConceptOneLiner('meta'),
|
||||
lastSignal: `Última atualização da equipe ou declaração de liderança, o que for mais recente. Fica destacado a partir de ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias sem registro.`,
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ export const municipalityColumnLabels: Record<MunicipalityListColumnId, string>
|
|||
advisors: municipalityListSortLabels.coverage,
|
||||
/** B155 — the column has no sort/filter; the label stands alone in the picker. */
|
||||
leaderships: 'Lideranças',
|
||||
/** B157 — nova coluna entre Assessores e Tendência (grupo "rede"). */
|
||||
stateDeputies: 'Dobradinhas',
|
||||
trend: municipalityListSortLabels.trend,
|
||||
expectedVotes: municipalityListSortLabels.expectedVotes,
|
||||
/** The column shows the signal; `frescor` sorts by how old it is. */
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export const municipalityListSelect = {
|
|||
ibgeCode: true,
|
||||
zoneNumber: true,
|
||||
advisors: true,
|
||||
stateDeputies: true,
|
||||
priority: true,
|
||||
lastUpdateAt: true,
|
||||
expectedVotes: true,
|
||||
|
|
@ -67,6 +68,8 @@ export type MunicipalityListViewModel = {
|
|||
advisorIDs: number[]
|
||||
/** B155 — leadership ids linked to this município (reverse read of `leadership.municipalities`). */
|
||||
leadershipIDs: number[]
|
||||
/** B157 — vínculos de dobradinhas para a coluna estilo assessores. */
|
||||
stateDeputyIDs: number[]
|
||||
priority: 'alta' | 'normal'
|
||||
lastUpdateAt: string | null
|
||||
/**
|
||||
|
|
@ -115,6 +118,9 @@ export const toMunicipalityListViewModel = (
|
|||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null),
|
||||
leadershipIDs,
|
||||
stateDeputyIDs: (municipality.stateDeputies ?? [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null),
|
||||
priority: municipality.priority === 'alta' ? 'alta' : 'normal',
|
||||
lastUpdateAt: municipality.lastUpdateAt ?? null,
|
||||
lastSignalAt: resolveMunicipalityLastSignalAt(
|
||||
|
|
|
|||
|
|
@ -225,6 +225,75 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('coordinator creates a dobradinha inline in the list combobox (B157)', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenador Dobradinhas'),
|
||||
})
|
||||
const password = coordinator.password
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
// Unique per run (uuid + counter), so no catalog entry matches it — the
|
||||
// combobox must offer the create option, not an existing dobradinha.
|
||||
const deputyName = fixtures.value('Deputado Novo')
|
||||
const party = 'PCdoB'
|
||||
|
||||
await campaign.login(page, coordinator.email!, password)
|
||||
await page.goto(
|
||||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
|
||||
)
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
|
||||
const deputiesTrigger = page.getByRole('button', {
|
||||
name: `Editar dobradinhas em ${municipality.name}`,
|
||||
})
|
||||
await deputiesTrigger.click()
|
||||
const deputiesPopover = page.locator('[data-slot="popover-content"]')
|
||||
await expect(deputiesPopover).toBeVisible()
|
||||
|
||||
const search = deputiesPopover.getByRole('combobox', { name: 'Buscar dobradinha' })
|
||||
// "Cicrano (PCdoB)" → name "Cicrano", party "PCdoB" (B157 syntax).
|
||||
const createOption = deputiesPopover.getByRole('option', {
|
||||
name: new RegExp(`^Criar dobradinha.*${deputyName}`),
|
||||
})
|
||||
const chip = deputiesPopover.getByRole('button', {
|
||||
name: `Remover ${deputyName} (${party})`,
|
||||
})
|
||||
|
||||
// Same retry contract as B154: a click before hydration is a silent no-op,
|
||||
// and once the create succeeded the option is gone (count() guard).
|
||||
await expect(async () => {
|
||||
await search.fill(`${deputyName} (${party})`)
|
||||
if (await createOption.count()) await createOption.click({ timeout: 1_000 })
|
||||
await expect(chip).toBeVisible({ timeout: 5_000 })
|
||||
}).toPass({ timeout: 25_000 })
|
||||
|
||||
// Same popover, no reload: typing the party now must NOT offer to create a
|
||||
// duplicate — the search only lists ADDABLE items, so the assigned chip's
|
||||
// own name never appears as an option; the "Criar …" suppression is what
|
||||
// proves the RSC refresh brought the new deputy into the catalog.
|
||||
await search.fill(party)
|
||||
await expect(deputiesPopover.getByRole('option', { name: /Criar dobradinha/ })).toHaveCount(0)
|
||||
await expect(chip).toBeVisible()
|
||||
|
||||
// The popover stays open after the write (auto-save, not submit+close).
|
||||
await expect(deputiesPopover).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// Persistence: reload and reopen — the server holds both the deputy and
|
||||
// the assignment, not just the local optimistic state.
|
||||
await page.reload()
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
await page.getByRole('button', { name: `Editar dobradinhas em ${municipality.name}` }).click()
|
||||
await expect(
|
||||
page.locator('[data-slot="popover-content"]').getByRole('button', {
|
||||
name: `Remover ${deputyName} (${party})`,
|
||||
}),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('coordinator sets trend status and justification with auto-save (B24)', async ({
|
||||
campaign,
|
||||
page,
|
||||
|
|
|
|||
175
tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts
Normal file
175
tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createMunicipalityStateDeputyRecord } from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import { STATE_DEPUTY_CONFLICT_MESSAGE } from '@/lib/schemas/stateDeputy'
|
||||
import config from '@/payload.config'
|
||||
|
||||
import { installCampaignFixtures, relationIds } 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 relationIds(municipality.stateDeputies)
|
||||
}
|
||||
|
||||
const stateDeputyByName = async (name: string) => {
|
||||
const result = await payload.find({
|
||||
collection: 'stateDeputy',
|
||||
where: { name: { equals: name } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return result.docs[0]
|
||||
}
|
||||
|
||||
describe('createMunicipalityStateDeputyRecord (B157)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it.each(['coordinator', 'candidate'] as const)(
|
||||
'lets a %s create a dobradinha and link it to the município in one write',
|
||||
async (role) => {
|
||||
const fixtures = campaignFixtures()
|
||||
const actor = await fixtures.createCampaignUser(role)
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const name = fixtures.value('Cicrano')
|
||||
|
||||
const { stateDeputy, municipalitySlug } = await createMunicipalityStateDeputyRecord(
|
||||
payload,
|
||||
actor,
|
||||
{ municipalityId: municipality.id, rawName: name },
|
||||
)
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
expect(municipalitySlug).toBe(municipality.slug)
|
||||
expect(stateDeputy.name).toBe(name)
|
||||
expect(stateDeputy.party).toBeNull()
|
||||
expect(await stateDeputyIdsOf(municipality.id)).toEqual([stateDeputy.id])
|
||||
},
|
||||
)
|
||||
|
||||
it('parses a trailing (PARTIDO) group and persists the party', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const name = fixtures.value('Cicrano')
|
||||
const party = fixtures
|
||||
.value('PCdoB')
|
||||
.replace(/-\w+-\d+$/, '')
|
||||
.slice(0, 32)
|
||||
|
||||
const { stateDeputy } = await createMunicipalityStateDeputyRecord(payload, coordinator, {
|
||||
municipalityId: municipality.id,
|
||||
rawName: `${name} (${party})`,
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
expect(stateDeputy.name).toBe(name)
|
||||
expect(stateDeputy.party).toBe(party)
|
||||
expect((await stateDeputyByName(name))?.party).toBe(party)
|
||||
})
|
||||
|
||||
it('refuses a name that only parses to a party group', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
await expect(
|
||||
createMunicipalityStateDeputyRecord(payload, coordinator, {
|
||||
municipalityId: municipality.id,
|
||||
rawName: '(PT)',
|
||||
}),
|
||||
).rejects.toThrow(/entre 2 e 160/)
|
||||
expect(await stateDeputyIdsOf(municipality.id)).toEqual([])
|
||||
})
|
||||
|
||||
it('maps a duplicate name to the safe conflict message', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const existing = await fixtures.createStateDeputy()
|
||||
|
||||
await expect(
|
||||
createMunicipalityStateDeputyRecord(payload, coordinator, {
|
||||
municipalityId: municipality.id,
|
||||
rawName: existing.name,
|
||||
}),
|
||||
).rejects.toThrow(STATE_DEPUTY_CONFLICT_MESSAGE)
|
||||
expect(await stateDeputyIdsOf(municipality.id)).toEqual([])
|
||||
})
|
||||
|
||||
it('rolls the create back when the assign is out of the actor scope', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const outside = await fixtures.getMunicipality()
|
||||
const name = fixtures.value('Fora do escopo')
|
||||
|
||||
await expect(
|
||||
createMunicipalityStateDeputyRecord(payload, advisor, {
|
||||
municipalityId: outside.id,
|
||||
rawName: name,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
// The create must roll back with the failed assign — no orphan deputy.
|
||||
expect(await stateDeputyByName(name)).toBeUndefined()
|
||||
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 name = fixtures.value('Só staff')
|
||||
|
||||
await expect(
|
||||
createMunicipalityStateDeputyRecord(payload, leaderActor, {
|
||||
municipalityId: municipality.id,
|
||||
rawName: name,
|
||||
}),
|
||||
).rejects.toThrow(/coordenação e a assessoria/i)
|
||||
expect(await stateDeputyByName(name)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('serializes concurrent creates on the same município through the advisory lock', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
createMunicipalityStateDeputyRecord(payload, coordinator, {
|
||||
municipalityId: municipality.id,
|
||||
rawName: fixtures.value('Primeiro'),
|
||||
}),
|
||||
createMunicipalityStateDeputyRecord(payload, coordinator, {
|
||||
municipalityId: municipality.id,
|
||||
rawName: fixtures.value('Segundo'),
|
||||
}),
|
||||
])
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
expect((await stateDeputyIdsOf(municipality.id)).sort((a, b) => a - b)).toEqual(
|
||||
[first.stateDeputy.id, second.stateDeputy.id].sort((a, b) => a - b),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -20,6 +20,7 @@ import { TooltipProvider } from '@/components/ui/tooltip'
|
|||
import type { CampaignColumnVisibility } from '@/lib/campaignColumnVisibility'
|
||||
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
|
||||
import { createEmptyGoalCoverageByScenario } from '@/utilities/municipality/goalCoverage'
|
||||
import { municipalityPriorityIndicatorLabel } from '@/utilities/municipality/municipalityLabels'
|
||||
import type {
|
||||
|
|
@ -44,6 +45,7 @@ const noopListFormAction = async (
|
|||
|
||||
const municipalityListDefaultProps = {
|
||||
isCoordinator: false,
|
||||
isCampaignUnrestricted: false,
|
||||
columnVisibility: {
|
||||
listId: 'municipios',
|
||||
hiddenColumnIds: [],
|
||||
|
|
@ -52,6 +54,9 @@ const municipalityListDefaultProps = {
|
|||
advisorOptions: [],
|
||||
leadershipNamesById: new Map(),
|
||||
leadershipOptions: [],
|
||||
stateDeputyOptions: [],
|
||||
stateDeputyCommitAction: noopListFormAction,
|
||||
stateDeputyCreateAction: async () => ({}),
|
||||
columnFilterOptions: { name: [], region: [], advisor: [] },
|
||||
signalFormAction: noopListFormAction,
|
||||
state: { page: 1 },
|
||||
|
|
@ -180,6 +185,7 @@ describe('campaign visual foundation', () => {
|
|||
zoneNumber: null,
|
||||
advisorIDs: [advisor.id],
|
||||
leadershipIDs: [11],
|
||||
stateDeputyIDs: [],
|
||||
priority: 'alta',
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
|
|
@ -224,6 +230,7 @@ describe('campaign visual foundation', () => {
|
|||
zoneNumber: 3,
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
stateDeputyIDs: [],
|
||||
priority: 'normal',
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
|
|
@ -285,6 +292,122 @@ describe('campaign visual foundation', () => {
|
|||
expect(tbodyHtml).toContain('Seabra')
|
||||
})
|
||||
|
||||
it('renders the Dobradinhas column for unrestricted staff with the advisors-style display', () => {
|
||||
const stateDeputyOptions: StateDeputyRelationOption[] = [
|
||||
{ id: 11, name: 'Fulano (PT)', plainName: 'Fulano', party: 'PT', slug: 'fulano' },
|
||||
{ id: 12, name: 'Beltrana (PSB)', plainName: 'Beltrana', party: 'PSB', slug: 'beltrana' },
|
||||
]
|
||||
const baseRow = stub<MunicipalityListViewModel>({
|
||||
id: 1,
|
||||
name: 'Seabra',
|
||||
slug: 'seabra',
|
||||
kind: 'municipio',
|
||||
city: 'Seabra',
|
||||
region: 'Chapada Diamantina',
|
||||
ibgeCode: '2929800',
|
||||
zoneNumber: null,
|
||||
priority: 'normal',
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
politicalTrendStatus: null,
|
||||
politicalTrendNote: null,
|
||||
engagementLevel: null,
|
||||
levelNote: null,
|
||||
levelChangedAt: null,
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||||
territorialClass: 'sem_base',
|
||||
territorialClassFactors: [],
|
||||
})
|
||||
const municipalities: MunicipalityListViewModel[] = [
|
||||
{ ...baseRow, id: 1, stateDeputyIDs: [11, 12] },
|
||||
{ ...baseRow, id: 2, name: 'Uauá', slug: 'uaua', stateDeputyIDs: [] },
|
||||
]
|
||||
|
||||
const html = renderWithAppRouter(
|
||||
createElement(MunicipalityList, {
|
||||
municipalities,
|
||||
advisorNamesById: new Map(),
|
||||
...municipalityListDefaultProps,
|
||||
isStaffView: true,
|
||||
isCampaignUnrestricted: true,
|
||||
stateDeputyOptions,
|
||||
}),
|
||||
)
|
||||
|
||||
// B157: the new column's header explanation makes it 12 staff tooltips
|
||||
// (11 with B155's Lideranças column + this one).
|
||||
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(12)
|
||||
expect(theadHtml).toContain('Dobradinhas')
|
||||
// Avatar-stack display: initials circles + the names in the trigger's
|
||||
// accessible label (the closed cell reads WHO is assigned without opening).
|
||||
expect(html).toContain('>F<') // Fulano
|
||||
expect(html).toContain('>B<') // Beltrana
|
||||
expect(html).toContain(
|
||||
'aria-label="Editar dobradinhas em Seabra — Fulano (PT), Beltrana (PSB)"',
|
||||
)
|
||||
// Empty município reads "—".
|
||||
expect(html).toContain('>—<')
|
||||
})
|
||||
|
||||
it('keeps the Dobradinhas column out of the advisor and leader views', () => {
|
||||
const municipalities: MunicipalityListViewModel[] = [
|
||||
stub<MunicipalityListViewModel>({
|
||||
id: 1,
|
||||
name: 'Seabra',
|
||||
slug: 'seabra',
|
||||
kind: 'municipio',
|
||||
city: 'Seabra',
|
||||
region: 'Chapada Diamantina',
|
||||
ibgeCode: '2929800',
|
||||
zoneNumber: null,
|
||||
priority: 'normal',
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
stateDeputyIDs: [],
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
politicalTrendStatus: null,
|
||||
politicalTrendNote: null,
|
||||
engagementLevel: null,
|
||||
levelNote: null,
|
||||
levelChangedAt: null,
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||||
territorialClass: 'sem_base',
|
||||
territorialClassFactors: [],
|
||||
}),
|
||||
]
|
||||
|
||||
const advisorHtml = renderWithAppRouter(
|
||||
createElement(MunicipalityList, {
|
||||
municipalities,
|
||||
advisorNamesById: new Map(),
|
||||
...municipalityListDefaultProps,
|
||||
isStaffView: true,
|
||||
// Advisor: staff view, but NOT unrestricted — the column must not render.
|
||||
isCampaignUnrestricted: false,
|
||||
}),
|
||||
)
|
||||
expect(advisorHtml).not.toContain('Dobradinhas')
|
||||
|
||||
const leaderHtml = renderWithAppRouter(
|
||||
createElement(MunicipalityList, {
|
||||
municipalities,
|
||||
advisorNamesById: new Map(),
|
||||
...municipalityListDefaultProps,
|
||||
isStaffView: false,
|
||||
isCampaignUnrestricted: false,
|
||||
}),
|
||||
)
|
||||
expect(leaderHtml).not.toContain('Dobradinhas')
|
||||
})
|
||||
|
||||
/**
|
||||
* E10: the class is a suggestion, and a suggestion without its reason reads
|
||||
* as a verdict (research §6.4). The reason moved into the column's
|
||||
|
|
@ -306,6 +429,7 @@ describe('campaign visual foundation', () => {
|
|||
region: 'Chapada Diamantina',
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
stateDeputyIDs: [],
|
||||
priority: 'normal',
|
||||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
|
|
@ -326,6 +450,7 @@ describe('campaign visual foundation', () => {
|
|||
region: 'Chapada Diamantina',
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
stateDeputyIDs: [],
|
||||
priority: 'normal',
|
||||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
|
|
@ -371,6 +496,7 @@ describe('campaign visual foundation', () => {
|
|||
zoneNumber: null,
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
stateDeputyIDs: [],
|
||||
priority: 'alta',
|
||||
lastUpdateAt: staleSignal,
|
||||
lastSignalAt: staleSignal,
|
||||
|
|
@ -416,6 +542,7 @@ describe('campaign visual foundation', () => {
|
|||
zoneNumber: null,
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
stateDeputyIDs: [],
|
||||
priority: 'normal',
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
|
|
@ -458,6 +585,7 @@ describe('campaign visual foundation', () => {
|
|||
zoneNumber: null,
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
stateDeputyIDs: [],
|
||||
priority: 'normal',
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
|
|
@ -506,6 +634,7 @@ describe('campaign visual foundation', () => {
|
|||
zoneNumber: null,
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
stateDeputyIDs: [],
|
||||
priority: 'alta',
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
|
|
@ -599,7 +728,7 @@ describe('campaign visual foundation', () => {
|
|||
|
||||
const withAdvisors = renderWithTooltip(
|
||||
createElement(MunicipalityList, {
|
||||
municipalities: [{ ...baseMunicipality, advisorIDs: [7] }],
|
||||
municipalities: [{ ...baseMunicipality, advisorIDs: [7], stateDeputyIDs: [8] }],
|
||||
advisorNamesById: new Map([[7, { id: 7, name: 'Ana Bastos', phone: null }]]),
|
||||
isStaffView: true,
|
||||
...municipalityListDefaultProps,
|
||||
|
|
@ -611,7 +740,7 @@ describe('campaign visual foundation', () => {
|
|||
|
||||
const withoutAdvisors = renderWithTooltip(
|
||||
createElement(MunicipalityList, {
|
||||
municipalities: [{ ...baseMunicipality, advisorIDs: [] }],
|
||||
municipalities: [{ ...baseMunicipality, advisorIDs: [], stateDeputyIDs: [] }],
|
||||
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
|
||||
isStaffView: true,
|
||||
...municipalityListDefaultProps,
|
||||
|
|
|
|||
52
tests/unit/stateDeputyNameParty.unit.spec.ts
Normal file
52
tests/unit/stateDeputyNameParty.unit.spec.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseStateDeputyNameParty } from '@/lib/stateDeputyNameParty'
|
||||
|
||||
describe('parseStateDeputyNameParty (B157)', () => {
|
||||
it('returns the trimmed name with no party when there are no parens', () => {
|
||||
expect(parseStateDeputyNameParty('Cicrano')).toEqual({ name: 'Cicrano', party: null })
|
||||
expect(parseStateDeputyNameParty(' Cicrano de Tal ')).toEqual({
|
||||
name: 'Cicrano de Tal',
|
||||
party: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('extracts a trailing parenthesized group as the party', () => {
|
||||
expect(parseStateDeputyNameParty('Cicrano (PCdoB)')).toEqual({
|
||||
name: 'Cicrano',
|
||||
party: 'PCdoB',
|
||||
})
|
||||
expect(parseStateDeputyNameParty('Fulano (PT) ')).toEqual({ name: 'Fulano', party: 'PT' })
|
||||
expect(parseStateDeputyNameParty('Fulana da Silva (PSB)')).toEqual({
|
||||
name: 'Fulana da Silva',
|
||||
party: 'PSB',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps parens that are not a trailing group as part of the name', () => {
|
||||
// The doc's rule is ONE group at the very end; anything else stays in the name.
|
||||
expect(parseStateDeputyNameParty('Fulano (PT) da Silva')).toEqual({
|
||||
name: 'Fulano (PT) da Silva',
|
||||
party: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('takes the LAST group when the text ends in several', () => {
|
||||
expect(parseStateDeputyNameParty('Fulano (PT) (PSB)')).toEqual({
|
||||
name: 'Fulano (PT)',
|
||||
party: 'PSB',
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a whole-string group with an empty name', () => {
|
||||
// "(PT)" must not mint a deputy named "(PT)": the schema rejects the empty name.
|
||||
expect(parseStateDeputyNameParty('(PT)')).toEqual({ name: '', party: 'PT' })
|
||||
})
|
||||
|
||||
it('trims the party', () => {
|
||||
expect(parseStateDeputyNameParty('Cicrano ( PCdoB )')).toEqual({
|
||||
name: 'Cicrano',
|
||||
party: 'PCdoB',
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue