mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:05:19 +00:00
feat(campanha): B155+ — migrar quick-edits de célula de lideranças para rota JSON (célula → rota)
F2: POST /campanha/liderancas/state-deputies substitui setLeadershipStateDeputyMembershipFormAction (chips de /campanha/liderancas B31 e /campanha/dobradinhas B36 — mesma action record). F3: POST /campanha/liderancas/municipalities substitui setLeadershipMunicipalitiesFormAction (B34). Wrappers 'use client' LeadershipStateDeputiesColumnCell / LeadershipMunicipalitiesColumnCell mantêm o contrato (state, formData) das shared cells (intocadas); rotas reusam os schemas exportados + allowlists floor/cap/scope; revalidate herdado das actions record (contrato B34/B31). F1 (sinal de municípios) não existe mais: C87 o substituiu por form multi-campo, que por regra B155 permanece em server action — registrado como já resolvido no plano de intenção. Guards: whitelist sharedSheetHostConventions (+2 wrappers), prewarm e2e (+2 rotas), B34 aponta para a rota nova. Sem migration/UI (Impeccable A). Closes #368
This commit is contained in:
parent
3d4ddc08e3
commit
e4fc5a9d5f
16 changed files with 413 additions and 134 deletions
1
docs/changelog/2026-08-24-b155-plus.md
Normal file
1
docs/changelog/2026-08-24-b155-plus.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**Recently resolved (2026-08-24):** **B155+** (Issue #368) — padrão de escrita de célula: migra os quick-edits restantes de form action para rota JSON. **F1 (sinal de municípios) morreu antes do lote** — C87 (2026-08-07) trocou o quick-edit por form multi-campo (`MunicipalityListUpdateControl`), que per a regra B155 corretamente permanece em server action; registrado como "já resolvido" no plano de intenção. **F2+F3 entregues:** `POST /campanha/liderancas/state-deputies` e `/campanha/liderancas/municipalities` via `campaignJsonMutationRoute` (schemas reusados `leadershipStateDeputyMembershipSchema`/`leadershipMunicipalitiesMembershipSchema`; allowlists floor/cap/scope/cap-dobradinhas; revalidate herdado das actions record — contrato B34/B31 preservado, a lista onde o chip está não é revalidada). Call sites trocados por wrappers `'use client'` de mesma assinatura `(state, formData) → postCampaignJson` (`LeadershipStateDeputiesColumnCell`, `LeadershipMunicipalitiesColumnCell` em `src/components/campaign/leadership/`), shared cells intocadas (multi-consumidor: dobradinhas/assessores/pessoas). **B36 incluído de graça** (coluna "Lideranças" de `/campanha/dobradinhas` usa a mesma action record da rota F2 — 3 form actions removidas no total). **Deferido com gatilho:** chips B37/B156 de `/campanha/dobradinhas` (domínio stateDeputy) e inline-edits por campo B153/B163/C129 (contrato de célula próprio) — rotas F2/F3 já nascem prontas para os dois lados. Guards atualizados: whitelist do `sharedSheetHostConventions` (+2 wrappers, padrão `PeopleMunicipalityCell`), prewarm e2e (+2 rotas), e2e B34 aponta para `/campanha/liderancas/municipalities`. Sem migration/access/Consent/UI/schema (Impeccable A).
|
||||
163
docs/plans/escala-dry-pos-b155-padrao-rota-impl.md
Normal file
163
docs/plans/escala-dry-pos-b155-padrao-rota-impl.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# Impl: Escala/DRY pós-B155 — padrão de escrita de célula: quick-edits via form action → rota JSON
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-24
|
||||
Issue: #368
|
||||
Intenção: docs/plans/escala-dry-pos-b155-padrao-rota.md
|
||||
Appetite restante: herdado (~0,5–1 dia eng; sem migration)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** As anomalias de quick-edit de célula ainda via form action migram para o padrão `campaignJsonMutationRoute` + `postCampaignJson` (mesmo transporte das 8+ rotas irmãs), reusando as actions record sem mudança de domínio, transporte apenas (Impeccable A), sem migration.
|
||||
- **O que NÃO negociar:** `campaignJsonMutationRoute` é obrigatório (o sweep de `codebaseConventions.unit.spec.ts` recusa POST nu); as actions record existentes são reusadas sem mudança de acesso/revalidação; o contrato de revalidação B34/B31 é preservado (a lista `/campanha/liderancas` onde o chip está NÃO é revalidada — `leadership.ts:630-639`); copy pt-BR, identificadores em inglês.
|
||||
- **O que reavaliar** (estado do main em 2026-08-24, 798 commits depois da intenção):
|
||||
- **F1 está MORTA — fora do lote, registrar como "já resolvido no simplify/fora".** O commit C87 (`212a1f24`, 2026-08-07, "atualização unificada (texto + polaridade + urgente)") removeu `MunicipalityListSignalControl` e `createMunicipalityListSignalFormAction` (grep em `src/`: zero ocorrências de ambos) e os substituiu pelo form multi-campo unificado `MunicipalityListUpdateControl` (`src/components/campaign/municipality/MunicipalityListUpdateControl.tsx` — `useActionState` + `startTransition` explícito em `:60`/`:90`, **sem** `useCampaignCellAutosave`) com `createMunicipalityListUpdateFormAction` (`municipalityStaffFormActions.ts:103`, wired em `municipios/page.tsx:193` como `signalFormAction`). Per a regra travada B155 (form multi-campo → server action), essa superfície CORRETAMENTE permanece em server action. Nada a migrar. `municipio/[slug]/v2/formActions.ts:3-9` já é shim de `createMunicipalityListUpdateFormAction` — nenhum símbolo morto restante. Atualizar o plano de intenção: as fases 1 e 3 (F1 + menção ao sinal em `politica de rotas`) saem; restam F2 + F3.
|
||||
- **F2 e F3 continuam vivas exatamente como escopadas** (ver "Abordagem recomendada"). A página de lideranças agora é lista virtualizada (B161, `CampaignListResults`) — as células passam por `leadershipColumns` em `liderancas/page.tsx`; os call sites dos chips continuam `:232-243` (Municípios) e `:257-270` (Dobradinhas).
|
||||
- **`useCampaignCellFailureChannel` não é necessário neste lote:** o contrato confirmado (`src/components/campaign/shared/useCampaignCellFailureChannel.ts:19` — `{ errorMessage, setErrorMessage, reportFailure, noteOpenChange }`) serve controles de lista auto-save (F1 morto). Nos F2/F3 o feedback já vive na shared cell (`RelationChipCell`/`RelationOptionCell` — Alert inline + toast de undo C128), que consome o retorno `CampaignFormActionState` do `commitAction`/`membershipAction`. O shim só traduz o transporte.
|
||||
- **Schemas já exportados:** `leadershipStateDeputyMembershipSchema` (`src/lib/schemas/leadership.ts:148`) e `leadershipMunicipalitiesMembershipSchema` (`:164`, com `.min(1).max(MAX_LEADERSHIP_MUNICIPALITIES)` + dedupe) são exatamente os bodies JSON das rotas F2/F3 — reusar, não colar.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph F2["F2 — Dobradinhas (coluna de /campanha/liderancas)"]
|
||||
C2[LeadershipStateDeputyRelationCell] -->|membershipAction = shim route-backed| S2[shim 'use client' LeadershipStateDeputiesColumnCell]
|
||||
S2 -->|postCampaignJson| R2[POST /campanha/liderancas/state-deputies]
|
||||
R2 -->|campaignJsonMutationRoute + leadershipStateDeputyMembershipSchema| A2[setLeadershipStateDeputyMembership]
|
||||
end
|
||||
subgraph F3["F3 — Municípios (coluna de /campanha/liderancas)"]
|
||||
C3[MunicipalityPortfolioCell] -->|commitAction = shim route-backed| S3[shim 'use client' LeadershipMunicipalitiesColumnCell]
|
||||
S3 -->|postCampaignJson| R3[POST /campanha/liderancas/municipalities]
|
||||
R3 -->|campaignJsonMutationRoute + leadershipMunicipalitiesMembershipSchema| A3[setLeadershipMunicipalitiesMembership]
|
||||
end
|
||||
A2 -->|revalidateLeadershipStateDeputyPaths<br/>lista NÃO revalidada| RV2
|
||||
A3 -->|revalidateLeadershipMunicipalityPaths<br/>lista NÃO revalidada \(B34/B31\)| RV3
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (refatorar as shared cells para transporte rota interno) | B (wrappers de coluna `'use client'` no call site da página, com shim route-backed de mesma assinatura) | C (prop `transport: 'form' | 'route'` nas shared cells) | D (shim solto sem wrapper, passado direto da página)
|
||||
|
||||
**Recomendação:** **B** — porque a página de lideranças é server component (não pode carregar `postCampaignJson`/hooks clientes), as shared cells têm múltiplos consumidores (dobradinhas B36/B37, assessores B156, pessoas C128) que não entram no lote, e `RelationChipCell` chama `commitAction({}, formData)` como função async regular dentro de `startTransition` (`RelationChipCell.tsx:555-558`) — qualquer função com a assinatura `(state: CampaignFormActionState, formData: FormData) => Promise<CampaignFormActionState>` funciona, sem `useActionState`. O wrapper `'use client'` encapsula o shim e repassa as props da página para a shared cell intocada. É exatamente a arquitetura da tentativa anterior (branch `origin/agent/368-padrao-rota-celula`, `e494fdca` — wrappers `LeadershipMunicipalitiesColumnCell`/`LeadershipStateDeputiesColumnCell` em `src/components/campaign/leadership/`), validada e reutilizada, menos o F1.
|
||||
|
||||
**Rejeitadas:**
|
||||
|
||||
- **A:** refatorar `RelationChipCell`/`RelationOptionCell` para `postCampaignJson` interno → quebra ou exige refatorar os consumidores fora do lote (dobradinhas, assessores, pessoas) e viola "transporte apenas".
|
||||
- **C:** prop de transporte nas shared cells → os dois modos coexistem para 2 call sites; o destino é toda célula em rota — não faz sentido manter o modo form.
|
||||
- **D:** passar o shim como import direto na página → página é server component; funções clientes não podem ser definidas/carregadas lá. O wrapper é o envelope mínimo que torna o shim legal.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`POST /campanha/liderancas/state-deputies/route.ts`** (novo, `src/app/(campaign)/campanha/(app)/liderancas/state-deputies/route.ts`): `export const POST = campaignJsonMutationRoute(` (literal exigido pelo sweep) com `bodySchema = leadershipStateDeputyMembershipSchema` (reuso direto de `src/lib/schemas/leadership.ts:148`), `safeMessages: [...leadershipStaffEditSafeMessages, LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE]`, `genericMessage: 'Não foi possível atualizar as dobradinhas. Tente novamente.'` (cópia da form action). Handler: `await setLeadershipStateDeputyMembership(body)` → `NextResponse.json({ status: 'success', message: 'Dobradinhas atualizadas.' })`. `types.ts` colado: `LeadershipStateDeputiesToggleResponse = { status: 'success'; message: string } | { status: 'error'; message: string }` (padrão `liderancas/support-status/types.ts`). `export const dynamic = 'force-dynamic'` (padrão das irmãs).
|
||||
- **`POST /campanha/liderancas/municipalities/route.ts`** (novo, `.../liderancas/municipalities/route.ts`): `bodySchema = leadershipMunicipalitiesMembershipSchema` (o cap já é o ceiling do schema; o floor de 1 é a action record contra o array resultante), `safeMessages: [...leadershipStaffEditSafeMessages, LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE, LEADERSHIP_MUNICIPALITY_CAP_MESSAGE, LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE]`, `genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.'`. Handler: `await setLeadershipMunicipalitiesMembership(body)` → `{ status: 'success', message: 'Municípios atualizados.' }`. `types.ts` + `force-dynamic` iguais. **Nota de body:** a shared cell envia `ownerId` no FormData; o shim converte para `leadershipId` no JSON (a action record exige `leadershipId`).
|
||||
- **`LeadershipStateDeputiesColumnCell`** (novo, `src/components/campaign/leadership/LeadershipStateDeputiesColumnCell.tsx`, `'use client'`): wrapper que recebe as props da página, monta com `useCallback` o shim `(state, formData) => Promise<CampaignFormActionState>` — lê `leadershipId`/`stateDeputyId`/`assigned` do FormData (parse idêntico ao de `liderancas/formActions.ts:92-95`, hoje `requiredRelationshipFormValue`/`requiredFormBoolean`) e chama `postCampaignJson('/campanha/liderancas/state-deputies', { leadershipId, stateDeputyId, assigned })`; `ok` → `{ status: 'success', message: payload.message }`; senão → `{ message: payload.message }` (sem `status` — o caminho de erro da shared cell reverter o otimismo). Renderiza `LeadershipStateDeputyRelationCell` com `membershipAction={shim}` e o restante das props repassadas.
|
||||
- **`LeadershipMunicipalitiesColumnCell`** (novo, `src/components/campaign/leadership/LeadershipMunicipalitiesColumnCell.tsx`, `'use client'`): idem para `MunicipalityPortfolioCell` com `commitAction={shim}`; o shim lê `ownerId` (campo que a cell envia — `MunicipalityPortfolioCell.tsx:190`), `municipalityIds` repetidos e `assigned`, e chama `postCampaignJson('/campanha/liderancas/municipalities', { leadershipId: ownerId, municipalityIds, assigned })`.
|
||||
- **`liderancas/page.tsx`** (editado): troca `MunicipalityPortfolioCell` (`:232-243`) por `LeadershipMunicipalitiesColumnCell` (remove a prop `commitAction`) e `LeadershipStateDeputyRelationCell` (`:257-270`) por `LeadershipStateDeputiesColumnCell` (remove `membershipAction`); remove os imports de `./formActions` (`:83-84`). O restante (filter head, drawerTitle, min/max, options) permanece.
|
||||
- **`liderancas/formActions.ts`** (editado): remove `setLeadershipStateDeputyMembershipFormAction` (`:86-101`) e `setLeadershipMunicipalitiesFormAction` (`:109-124`). `updateLeadershipContactFormAction` (B153, `:42-83`) PERMANECE — fora do lote (ver decisão de espelhos). Limpar imports órfãos (`requiredFormBoolean`, `repeatedRelationshipFormValues`, `LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`, floor/cap/scope) e reenxugar o `safeMessages` local para o de B153.
|
||||
- **`tests/e2e/setup.e2e.spec.ts`** (editado): adicionar `'/campanha/liderancas/state-deputies'` e `'/campanha/liderancas/municipalities'` ao prewarm de POSTs (`:73-93`).
|
||||
- **`tests/e2e/campaignLeaderships.e2e.spec.ts`** (editado): `:102` — `expectPostResponse(page, '/campanha/liderancas')` → `expectPostResponse(page, '/campanha/liderancas/municipalities')`; atualizar o comentário acima (não há mais server action POSTando na URL da página para chips — o POST agora é a rota JSON).
|
||||
- **`tests/e2e/campaignMunicipalities.e2e.spec.ts`**: **sem mudança** — verificado: `:319` (B157) e `:470-522` (B176) cobrem a coluna "Dobradinhas" da página de MUNICÍPIOS (`setMunicipalityStateDeputiesFormAction`/`createMunicipalityStateDeputyFormAction`, wired em `municipios/page.tsx:190-191`) — superfície fora do lote, segue em form action.
|
||||
- **`tests/unit/campaignCellEditOverlay.unit.spec.ts`**: **sem mudança obrigatória** — importa `MunicipalityPortfolioCell` (shared, intocada); as células de liderança não estão nos `overlayCases`. Conferir na fase 2 se vale adicionar os wrappers (opcional, sem pressão de teste).
|
||||
- **`tests/unit/codebaseConventions.unit.spec.ts`**: sem mudança — sweep automático (`:227-244`); as rotas novas nascem com o literal `export const POST = campaignJsonMutationRoute(`.
|
||||
- **Migration:** nenhuma. **Access / Consent:** sem mudança — `getCampaignActionContext` (`src/utilities/campaignActionContext.ts:88`) lê a sessão via cookie e lança `CAMPAIGN_AUTH_REQUIRED_MESSAGE`/`CAMPAIGN_SESSION_EXPIRED_MESSAGE`, que o wrapper mapeia para 401 (`campaignJsonMutationRoute.ts:55-73`); as actions record já usam `overrideAccess: false`. A rota não duplica lógica de access além do que o wrapper exige (same-origin 403 + safe-messages). **UI:** Impeccable A — transporte apenas; nenhuma shape/craft/critique.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
Não se aplica (refactor de transporte; a apresentação dos chips/undo/toast é das shared cells e não muda).
|
||||
|
||||
## Decisões de engenharia
|
||||
|
||||
### F1: registrar como já resolvido
|
||||
|
||||
**Situação:** A intenção cataloga 3 anomalias; o main só tem 2. C87 (`212a1f24`) trocou o sinal por form multi-campo unificado, e a regra B155 manda form multi-campo para server action — a superfície atual está CORRETA.
|
||||
|
||||
**Decisão:** F1 entra na seção "Já resolvido no simplify (não reabrir)" do plano de intenção — zero trabalho neste lote. A seção de fases da intenção ("F1 primeiro — ROI mais alto") é reescrita para F2+F3.
|
||||
|
||||
**Rejeitadas:** migrar `createMunicipalityListUpdateFormAction` para rota mesmo assim (violaria a regra travada B155 — form multi-campo, submit explícito, campos interdependentes); reabrir o quick-edit de sinal como célula auto-save (C87 foi deliberação de produto fechada).
|
||||
|
||||
### Shim route-backed vs refatoração interna da shared cell
|
||||
|
||||
**Opções:** A (wrappers de coluna `'use client'` com shim `(state, formData) => Promise<CampaignFormActionState>` chamando `postCampaignJson`) | B (refatorar `RelationChipCell`/`RelationOptionCell` para rota interno) | C (prop `transport`)
|
||||
|
||||
**Recomendação:** A — o contrato de chamada das shared cells já é uma função async regular (`RelationChipCell.tsx:555-558`), o wrapper é o envelope mínimo que legaliza um shim client-side numa página server, e as shared cells (multi-consumidor) ficam intocadas — o mesmo desenho da tentativa anterior, reaproveitado integralmente para F2/F3.
|
||||
|
||||
**Rejeitadas:** B porque exige tocar consumidores fora do lote (dobradinhas, assessores, pessoas) e muda transporte onde o lote não migra; C porque mantém dois transportes vivos nas cells para 2 call sites — o destino é rota para tudo.
|
||||
|
||||
### Rotas reusam os schemas exportados vs colar schema local
|
||||
|
||||
**Opções:** A (reusar `leadershipStateDeputyMembershipSchema`/`leadershipMunicipalitiesMembershipSchema` de `src/lib/schemas/leadership.ts`) | B (colar `z.strictObject({...})` com `positiveRelationshipId` no route.ts, padrão das rotas irmãs mais antigas)
|
||||
|
||||
**Recomendação:** A — os dois schemas são exatamente os bodies JSON (o de municípios já tem `.min(1).max(30)` + dedupe, e o cap como ceiling do próprio schema, comentado em `schemas/leadership.ts:166-171`); o precedente próximo é `political-trend/route.ts:16-18`, que reusa `municipalityPoliticalTrendSchema`. Colar duplicaria a validação em dois lugares.
|
||||
|
||||
**Rejeitadas:** B porque cria dois schemas com a mesma forma em módulos vizinhos — DRY violado sem volatilidade que justifique.
|
||||
|
||||
### Destino das quick-edits espelho (fora deste lote — decidir e registrar)
|
||||
|
||||
**Situação:** `setLeadershipStateDeputyMembershipFormAction` em `dobradinhas/formActions.ts:129-144` (B36, coluna "Lideranças" de `/campanha/dobradinhas`, wired em `dobradinhas/page.tsx:266` — mesma action record do F2), `setStateDeputyMunicipalitiesFormAction` (B37, `dobradinhas/formActions.ts:170-174`), `setStateDeputyAdvisorMembershipFormAction` (B156, `dobradinhas/formActions.ts:147-162`), `updateStateDeputyPartyFormAction` (B163), `updateStateDeputyBallotNameFormAction` (C129, `dobradinhas/formActions.ts:115-126`), `updateLeadershipContactFormAction` (B153, `liderancas/formActions.ts:42-83`).
|
||||
|
||||
**Decisão:**
|
||||
|
||||
- **B36 / B37 / B156 → defer com gatilho** (não entram no lote para respeitar o appetite ~0,5–1 dia): gatilho = próximo lote de células em `/campanha/dobradinhas` — as rotas F2/F3 nascem prontas para os dois lados (mesma action record, mesmo body) e B156 tem a allowlist pronta (`LEADERSHIP_ADVISORS_CAP_MESSAGE`/`LEADERSHIP_ADVISORS_UNRESTRICTED_MESSAGE`, `schemas/leadership.ts:41-44`).
|
||||
- **B153 / B163 / C129 → defer com gatilho** de um lote dedicado a **inline-edits por campo** (`CampaignInlineEditableCell`, `liderancas/page.tsx:197`): não são chips/toggles de relação — o popover de edição de campo único tem outro contrato de chamada e precisa de tratamento próprio (rota por campo + cell editando via `postCampaignJson`). Fora deste lote, NÃO descartado permanentemente.
|
||||
- **Rejeitada:** "fora de escopo permanente" para qualquer um deles — a regra B155 (célula → rota) não distingue chip de inline-edit; descartar criaria um padrão duplo sem evidência de produto. O que fica permanente fora é o ladder de forms (`runCampaignFormAction` para forms multi-campo — B155), não essas células.
|
||||
|
||||
### Revalidação após mutation
|
||||
|
||||
**Situação:** `setLeadershipStateDeputyMembership` (`leadership.ts:615-627`) revalida `revalidateLeadershipStateDeputyPaths` (`:508`); `setLeadershipMunicipalitiesMembership` (`:759-773`) revalida `revalidateLeadershipMunicipalityPaths` (`:640-648`), com a lista `/campanha/liderancas` deliberadamente excluída (`:630-639` — contrato B34/B31).
|
||||
|
||||
**Decisão:** as rotas herdam isso automaticamente ao chamar as actions record — zero linhas de revalidação nas rotas, zero mudança de contrato. Adicionar a lista ao revalidate seria mudança de comportamento, não de transporte (mesma posição da tentativa anterior).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
### Fase 1 — Tracer: as 2 rotas + specs
|
||||
|
||||
1. Criar `liderancas/state-deputies/route.ts` + `types.ts` e `liderancas/municipalities/route.ts` + `types.ts` (schema reusado, safe-messages, `force-dynamic`).
|
||||
2. Adicionar as 2 rotas ao prewarm POST de `tests/e2e/setup.e2e.spec.ts:73-93`.
|
||||
3. `campaignLeaderships.e2e.spec.ts:102` → `expectPostResponse(page, '/campanha/liderancas/municipalities')`.
|
||||
4. Verificar a rota no navegador (`pnpm dev` + sessão de campanha): POST `{ leadershipId, stateDeputyId, assigned: false }` e `{ leadershipId, municipalityIds: [..], assigned: true }` respondem `{ status: 'success', ... }`; body malformado → 400 com `genericMessage`; sem sessão → 401.
|
||||
5. Rodar o spec e2e de lideranças.
|
||||
|
||||
### Fase 2 — Shims e remoção das form actions
|
||||
|
||||
1. Criar `LeadershipStateDeputiesColumnCell` e `LeadershipMunicipalitiesColumnCell` em `src/components/campaign/leadership/` (shims route-backed com a mesma assinatura; parse do FormData copiado de `liderancas/formActions.ts`).
|
||||
2. Trocar os call sites em `liderancas/page.tsx:232-243` e `:257-270`; remover imports de `./formActions`.
|
||||
3. Remover as 2 form actions de `liderancas/formActions.ts`; limpar imports órfãos.
|
||||
4. `pnpm exec knip` (exports órfãos), `tsc --noEmit`, conferir `tests/unit/campaignCellEditOverlay.unit.spec.ts` (sem mudança esperada).
|
||||
|
||||
### Fase 3 — Gates
|
||||
|
||||
1. `pnpm gate:fast` (cascade: lint/format/typecheck/knip/cycles/unit/int).
|
||||
2. e2e afetados: `campaignLeaderships` (B32/B34) e `campaignMunicipalities` (regressão B157/B176 — devem passar intocados).
|
||||
3. `pnpm check:cycles`; `pnpm push` + PR + auto-merge.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Generalizar shared cells / fundir espelhos** durante a migração — os 2+2 controles seguem espelhos; refactor de transporte apenas.
|
||||
- **Migrar B36/B37/B156/B163/C129/B153** — defer com gatilho registrado acima; não entram nas fases.
|
||||
- **`MunicipalityListUpdateControl` / `createMunicipalityListUpdateFormAction` (ex-F1)** — form multi-campo; fica em server action por regra B155.
|
||||
- **Coluna "Dobradinhas" da página de municípios** (`setMunicipalityStateDeputiesFormAction`, `municipios/page.tsx:190-191`) — espelho de relação, mas outra superfície; não tocada.
|
||||
- **Mudar o contrato de revalidação B34/B31** (adicionar `/campanha/liderancas` ao revalidate) — comportamento, não transporte.
|
||||
- **`useCampaignCellFailureChannel` nos shims** — o feedback já é da shared cell; canal duplo seria camada sem volatilidade.
|
||||
- **Fundir com UX / copy** — Impeccable A; nenhum achado de critique pendente nesta superfície.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Shim diverge do contrato do FormData da shared cell:** `MunicipalityPortfolioCell` envia `ownerId` + `municipalityIds` repetidos + `assigned` (`MunicipalityPortfolioCell.tsx:187-199`); `LeadershipStateDeputyRelationCell` envia `leadershipId` + `stateDeputyId` + `assigned` (`:98-113`). **Mitigação:** o shim copia o parse das form actions atuais (`liderancas/formActions.ts:92-95` e `:115-118` — a fonte da verdade do formato), com `Number()` nos ids e `assigned` string→boolean; o e2e B34 (rota apontada) cobre o fluxo feliz e o undo C128 re-chama o shim pelo mesmo caminho.
|
||||
- **Erro fora de 200 (auth 401 / malformed 400 / safe-message 400):** `postCampaignJson` retorna `{ ok, payload }` (`campaignJsonRequest.ts:12-26`) — o shim propaga `payload.message`; `ok` separado do status porque há rotas que respondem 409 com body real. **Mitigação:** o shim só olha `ok` + `payload.message`, nunca o status.
|
||||
- **Página server component:** shim não pode viver em `liderancas/page.tsx`. **Mitigação:** wrappers `'use client'` (opção B) — o motivo estrutural da escolha.
|
||||
- **Sweep `codebaseConventions` recusa POST nu:** as rotas devem exportar o literal `export const POST = campaignJsonMutationRoute(` (`codebaseConventions.unit.spec.ts:227-244`). **Mitigação:** nascer no padrão, como as irmãs.
|
||||
- **Knip/imports órfãos após remover as form actions:** `requiredFormBoolean`, `repeatedRelationshipFormValues` e as constantes floor/cap/scope perdem uso em `liderancas/formActions.ts`. **Mitigação:** limpar no mesmo commit; `pnpm exec knip` na fase 2.
|
||||
- **`safeMessages` errados nas rotas:** mensagens de erro das actions record são match por EXACT string (`campaignJsonMutationRoute.ts:51-74`) — allowlist faltando colapsa em `genericMessage`. **Mitigação:** espelhar a lista da form action atual (que já era a allowlist do `runCampaignFormAction`), sem `LEADERSHIP_INVALID_CONTACT_MESSAGE`/`INVALID_FIELD_MESSAGE` (só pertencem ao B153).
|
||||
- **e2e flakiness da migração:** o B34 hoje espera POST na URL da página (`campaignLeaderships.e2e.spec.ts:102`) — se não migrar junto com o código, o teste fica pendurado até timeout. **Mitigação:** spec migrado na mesma fase do código (fase 1 antecipa a rota, fase 2 o call site — rodar o spec nas duas).
|
||||
- **B161 (lista virtualizada):** wrappers são componentes clientes comuns — compatíveis com `CampaignListResults`; nada de portal/scroll novo.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto — F2+F3 migrados para rota; F1 registrado como já resolvido no simplify/fora (C87)
|
||||
- [ ] Invariantes AGENTS/engineering-standards — `campaignJsonMutationRoute` obrigatório; actions record reusadas com `overrideAccess: false` e transação; contrato de revalidação B34/B31 preservado; copy pt-BR, identificadores em inglês
|
||||
- [ ] Testes de domínio previstos — e2e B34 apontando para `/campanha/liderancas/municipalities`; prewarm com as 2 rotas novas; regressão B157/B176 intocada
|
||||
- [ ] `codebaseConventions` — zero offenders no sweep automático
|
||||
- [ ] `knip` — zero exports órfãos; `pnpm check:cycles` — zero ciclos
|
||||
- [ ] Sem migration, sem mudança de acesso/consent, sem mudança de UI (Impeccable A)
|
||||
|
||||
## Self-score decision-quality
|
||||
|
||||
**5/5** — (1) decisões caras têm rejeitadas (shim vs refatorar cells vs prop transport; rotas reusam schema vs colam; destino dos espelhos decidido com alternativa rejeitada); (2) cabe no appetite herdado (~0,5–1 dia: 2 rotas + 2 wrappers + 2 remoções + 2 specs — a tentativa anterior provou o tamanho); (3) rabbit holes nomeados (espelhos, ex-F1, coluna de municípios, revalidação, canal de falha duplo); (4) depth check: reusa `campaignJsonMutationRoute`, `postCampaignJson`, actions record, schemas exportados, `leadershipStaffEditSafeMessages`, shared cells, prewarm existente — nenhum módulo novo além dos wrappers mínimos; (5) intenção satisfeita: transporte apenas, sem reescrever o outcome — com a divergência F1 explicitamente reconciliada no plano de intenção.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# Escala/DRY pós-B155 — padrão de escrita de célula: quick-edits via form action → rota JSON
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-04
|
||||
Atualizado em: 2026-08-24 (escopo revisado — F1 resolvida por C87; B36 entra)
|
||||
Issue: #368
|
||||
Prioridade: P2
|
||||
Item do roadmap: débito da entrega B155 (#359) — regra travada com o humano em 2026-08-04: **célula → rota, form → server action**
|
||||
|
|
@ -18,36 +18,45 @@ multi-campo** usam **server action** (`runCampaignFormAction`). O padrão rota
|
|||
`codebaseConventions.unit.spec.ts` recusa `POST` nu — com 8+ endpoints sob `(app)/municipios` +
|
||||
`liderancas/support-status`.
|
||||
|
||||
Restam **3 quick-edits de célula** que ainda passam por form action (as anomalias):
|
||||
Restam quick-edits de célula que ainda passam por form action (as anomalias):
|
||||
|
||||
1. **Sinal na lista de municípios** — `MunicipalityListSignalControl` recebe
|
||||
`signalFormAction` = `createMunicipalityListSignalFormAction` (debounce/abort via
|
||||
`useCampaignCellAutosave`), mesma superfície que já tem advisors/leaderships/level/trend
|
||||
por rota.
|
||||
2. **Coluna "Dobradinhas" de `/campanha/liderancas` (B31)** — `setLeadershipStateDeputyMembershipFormAction`.
|
||||
1. ~~**Sinal na lista de municípios** — ~~ **RESOLVIDO por C87 (2026-08-07):** o quick-edit de sinal
|
||||
(`MunicipalityListSignalControl`/`createMunicipalityListSignalFormAction`) foi substituído pelo
|
||||
form multi-campo unificado `MunicipalityListUpdateControl` (body/polaridade/urgente) — per a
|
||||
regra B155, form multi-campo permanece em server action. Nada a migrar.
|
||||
2. **Coluna "Dobradinhas" de `/campanha/liderancas` (B31)** — `setLeadershipStateDeputyMembershipFormAction`
|
||||
(mesma action record também na coluna "Lideranças" de `/campanha/dobradinhas`, B36 — a rota F2
|
||||
serve os dois lados).
|
||||
3. **Coluna "Municípios" de `/campanha/liderancas` (B34)** — `setLeadershipMunicipalitiesMembershipFormAction`
|
||||
(delta por chip e batch território/ZE).
|
||||
|
||||
**Deferido (fora deste lote):** colunas de `/campanha/dobradinhas` B37 (Municípios,
|
||||
`setStateDeputyMunicipalitiesBatch`) e B156 (Assessores), inline-edits por campo B153 (contatos de
|
||||
liderança), B163 (partido) e C129 (nome de legenda) — mesmas células-espelho em outros domínios;
|
||||
gatilho registrado no `*-impl.md`.
|
||||
|
||||
## Escopo
|
||||
|
||||
Migrar os 3 para o padrão rota, sem tocar comportamento:
|
||||
Migrar as anomalias vivas para o padrão rota, sem tocar comportamento:
|
||||
|
||||
- **F1 — sinal de municípios:** nova rota `POST /campanha/municipios/signal` (ou reusar o
|
||||
contrato do `political-trend`) via `campaignJsonMutationRoute`; o controle passa a chamar
|
||||
`postCampaignJson` mantendo debounce/abort; `createMunicipalityListSignalFormAction` vira
|
||||
wrapper interno ou é eliminada.
|
||||
- **F1 — ~~sinal de municípios~~** **não existe mais** — C87 substituiu o quick-edit por form
|
||||
multi-campo (fica em server action por regra B155). Registrado como "já resolvido", não reabrir.
|
||||
- **F2 — dobradinhas de liderança:** rota `POST /campanha/liderancas/state-deputies` (delta
|
||||
`{ leadershipId, stateDeputyId, assigned }`, allowlist do cap `LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`).
|
||||
`{ leadershipId, stateDeputyId, assigned }`, allowlist do cap `LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`),
|
||||
servindo os chips de `/campanha/liderancas` (B31) e `/campanha/dobradinhas` (B36).
|
||||
- **F3 — municípios de liderança:** rota `POST /campanha/liderancas/municipalities` (delta
|
||||
`{ leadershipId, municipalityIds[], assigned }` — inclui o batch território/ZE; allowlist de
|
||||
floor/cap/scope).
|
||||
|
||||
Cada fase reusa as **actions record já existentes** (sem mudança de domínio): o trabalho é
|
||||
transport (rota + allowlist) + o controle trocando `useActionState`/form action por
|
||||
`postCampaignJson` com o mesmo estado otimista/debounce que já tem.
|
||||
transport (rota + allowlist) + o call site trocando a form action por um shim cliente
|
||||
`postCampaignJson` com o mesmo estado otimista que as shared cells já têm.
|
||||
|
||||
## Já resolvido no simplify (não reabrir)
|
||||
|
||||
- **F1 (sinal na lista de municípios)** — resolvido por C87 (2026-08-07): o quick-edit virou form
|
||||
multi-campo `MunicipalityListUpdateControl`; per a regra B155 essa superfície permanece em
|
||||
server action (`createMunicipalityListUpdateFormAction`). Nada a migrar, não reabrir.
|
||||
- Reconcile scoped das wrappers B155 (sem bypass novo), toggle revalidando `/campanha/liderancas`,
|
||||
nome real do contato no chip otimista, validação cliente espelhando o schema, ids `useId`,
|
||||
refoco pós-form — todos aplicados na sessão B155.
|
||||
|
|
@ -65,23 +74,24 @@ transport (rota + allowlist) + o controle trocando `useActionState`/form action
|
|||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **F1** — sinal de municípios (mesma superfície já no padrão; ROI mais alto).
|
||||
2. **F2 + F3** — colunas de `/campanha/liderancas`.
|
||||
3. **Gates** — `pnpm gate:fast`; e2e afetado (campaignMunicipalities, campaignLeaderships);
|
||||
1. **F2 + F3** — colunas de `/campanha/liderancas` (+ B36 em `/campanha/dobradinhas`, mesma rota).
|
||||
2. **Gates** — `pnpm gate:fast`; e2e afetado (campaignLeaderships, campaignMunicipalities);
|
||||
`pnpm push`.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **Generalizar** os controles/providers de chips durante a migração — refactor de transporte
|
||||
apenas; os 2+2 controles seguem espelhos.
|
||||
- **Mudar o contrato do sinal** (hoje debounce com flush no close) — preservar
|
||||
`useCampaignCellAutosave` behavior no novo transporte.
|
||||
apenas; os controles espelho (dobradinhas B37/B156, inline-edits B153/B163/C129) seguem
|
||||
em form action até o lote próprio.
|
||||
- **Mudar o contrato do sinal** — não se aplica mais (F1 morta por C87); sem debounce/autosave
|
||||
novo no lote.
|
||||
- **Fundir com UX** — nenhum achado de critique nesta superfície; manter o lote só-engenharia.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- Controles de lideranças hoje acionam form actions com revalidate via server action — a rota
|
||||
precisa replicar o `revalidatePath` equivalente (as actions record reusadas já revalidam
|
||||
paths de detalhe; a lista onde o chip está NÃO é revalidada — contrato B34/B31).
|
||||
- A rota reusa as actions record (que já revalidam paths de detalhe; a lista onde o chip está
|
||||
NÃO é revalidada — contrato B34/B31) — o revalidate vem das actions, zero linhas na rota;
|
||||
adicionar a lista seria mudança de comportamento, não de transporte.
|
||||
- `codebaseConventions` (rota obrigatória via `campaignJsonMutationRoute`) e o prewarm do e2e
|
||||
(`setup.e2e.spec.ts`) ganham as rotas novas no mesmo PR.
|
||||
(`setup.e2e.spec.ts`) ganham as rotas novas no mesmo PR; o e2e B34 troca o filtro de POST da
|
||||
URL da página para a rota nova.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
'use server'
|
||||
|
||||
import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import {
|
||||
setStateDeputyAdvisorMembership,
|
||||
setStateDeputyMunicipalitiesBatch,
|
||||
|
|
@ -16,10 +15,6 @@ import {
|
|||
requiredFormText,
|
||||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
import {
|
||||
LEADERSHIP_STAFF_MESSAGE,
|
||||
LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import {
|
||||
STATE_DEPUTY_ADVISOR_SAFE_MESSAGES,
|
||||
STATE_DEPUTY_CONFLICT_MESSAGE,
|
||||
|
|
@ -33,8 +28,6 @@ import {
|
|||
type CampaignFormActionState,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
|
||||
const safeMessages = [LEADERSHIP_STAFF_MESSAGE, LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE] as const
|
||||
|
||||
const stateDeputyContactSafeMessages = [
|
||||
STATE_DEPUTY_STAFF_MESSAGE,
|
||||
STATE_DEPUTY_CONFLICT_MESSAGE,
|
||||
|
|
@ -125,24 +118,6 @@ export const updateStateDeputyBallotNameFormAction = async (
|
|||
'Não foi possível salvar o nome de legenda. Verifique os dados e tente novamente.',
|
||||
})
|
||||
|
||||
/** One chip toggle in the "Lideranças" column of `/campanha/dobradinhas` (B36). */
|
||||
export const setLeadershipStateDeputyMembershipFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await setLeadershipStateDeputyMembership({
|
||||
leadershipId: requiredRelationshipFormValue(formData, 'leadershipId'),
|
||||
stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'),
|
||||
assigned: requiredFormBoolean(formData, 'assigned'),
|
||||
})
|
||||
return { message: 'Lideranças atualizadas.' }
|
||||
},
|
||||
safeMessages,
|
||||
genericMessage: 'Não foi possível atualizar as lideranças. Tente novamente.',
|
||||
})
|
||||
|
||||
/** One chip toggle in the "Assessores" column/section of a dobradinha (B156). */
|
||||
export const setStateDeputyAdvisorMembershipFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Link from 'next/link'
|
|||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { LeadershipStateDeputiesColumnCell } from '@/components/campaign/leadership/LeadershipStateDeputiesColumnCell'
|
||||
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
|
||||
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
|
|
@ -19,10 +20,7 @@ import {
|
|||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import {
|
||||
LeadershipStateDeputyRelationCell,
|
||||
type RelationCellOption,
|
||||
} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
|
||||
import type { RelationCellOption } from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
|
||||
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { StateDeputyAdvisorRelationCell } from '@/components/campaign/stateDeputy/StateDeputyAdvisorRelationCell'
|
||||
|
|
@ -69,7 +67,6 @@ import {
|
|||
} from '@/utilities/stateDeputyListUrl'
|
||||
|
||||
import {
|
||||
setLeadershipStateDeputyMembershipFormAction,
|
||||
setStateDeputyAdvisorMembershipFormAction,
|
||||
setStateDeputyMunicipalitiesFormAction,
|
||||
updateStateDeputyBallotNameFormAction,
|
||||
|
|
@ -253,7 +250,7 @@ const stateDeputyColumns = (
|
|||
label: 'Lideranças',
|
||||
cellClassName: 'max-w-72 whitespace-normal',
|
||||
cell: (row) => (
|
||||
<LeadershipStateDeputyRelationCell
|
||||
<LeadershipStateDeputiesColumnCell
|
||||
direction="fromStateDeputy"
|
||||
fixedId={row.id}
|
||||
ownerName={row.name}
|
||||
|
|
@ -263,7 +260,6 @@ const stateDeputyColumns = (
|
|||
href: `/campanha/liderancas/${leadership.id}`,
|
||||
}))}
|
||||
options={leadershipOptions}
|
||||
membershipAction={setLeadershipStateDeputyMembershipFormAction}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,26 +1,14 @@
|
|||
'use server'
|
||||
|
||||
import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages'
|
||||
import {
|
||||
setLeadershipMunicipalitiesMembership,
|
||||
setLeadershipStateDeputyMembership,
|
||||
updateLeadershipContact,
|
||||
} from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import { updateLeadershipContact } from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import {
|
||||
nullableFormText,
|
||||
repeatedPhoneFormValues,
|
||||
repeatedRelationshipFormValues,
|
||||
requiredFormBoolean,
|
||||
requiredFormText,
|
||||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
import {
|
||||
LEADERSHIP_INVALID_CONTACT_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import { LEADERSHIP_INVALID_CONTACT_MESSAGE } from '@/lib/schemas/leadership'
|
||||
import {
|
||||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
|
|
@ -31,10 +19,6 @@ const INVALID_FIELD_MESSAGE = 'Campo inválido.'
|
|||
const safeMessages = [
|
||||
...leadershipStaffEditSafeMessages,
|
||||
LEADERSHIP_INVALID_CONTACT_MESSAGE,
|
||||
LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
INVALID_FIELD_MESSAGE,
|
||||
] as const
|
||||
|
||||
|
|
@ -81,44 +65,3 @@ export const updateLeadershipContactFormAction = async (
|
|||
safeMessages,
|
||||
genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.',
|
||||
})
|
||||
|
||||
/** One chip toggle in the "Dobradinhas" column of `/campanha/liderancas` (B31). */
|
||||
export const setLeadershipStateDeputyMembershipFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await setLeadershipStateDeputyMembership({
|
||||
leadershipId: requiredRelationshipFormValue(formData, 'leadershipId'),
|
||||
stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'),
|
||||
assigned: requiredFormBoolean(formData, 'assigned'),
|
||||
})
|
||||
return { message: 'Dobradinhas atualizadas.' }
|
||||
},
|
||||
safeMessages,
|
||||
genericMessage: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
|
||||
})
|
||||
|
||||
/**
|
||||
* Add or remove municipalities in the "Municípios" column of
|
||||
* `/campanha/liderancas` (B34) — one chip or a whole território/ZE. `ownerId` is
|
||||
* the field name `MunicipalityPortfolioCell` sends, so the same cell serves
|
||||
* lideranças and assessores.
|
||||
*/
|
||||
export const setLeadershipMunicipalitiesFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await setLeadershipMunicipalitiesMembership({
|
||||
leadershipId: requiredRelationshipFormValue(formData, 'ownerId'),
|
||||
municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
|
||||
assigned: requiredFormBoolean(formData, 'assigned'),
|
||||
})
|
||||
return { message: 'Municípios atualizados.' }
|
||||
},
|
||||
safeMessages,
|
||||
genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages'
|
||||
import { setLeadershipMunicipalitiesMembership } from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import {
|
||||
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
leadershipMunicipalitiesMembershipSchema,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
|
||||
|
||||
import type { LeadershipListMunicipalitiesResponse } from './types'
|
||||
|
||||
export type { LeadershipListMunicipalitiesResponse } from './types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const POST = campaignJsonMutationRoute(
|
||||
{
|
||||
bodySchema: leadershipMunicipalitiesMembershipSchema,
|
||||
safeMessages: [
|
||||
...leadershipStaffEditSafeMessages,
|
||||
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
],
|
||||
genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
|
||||
},
|
||||
async (body) => {
|
||||
await setLeadershipMunicipalitiesMembership(body)
|
||||
|
||||
return NextResponse.json<LeadershipListMunicipalitiesResponse>({
|
||||
status: 'success',
|
||||
message: 'Municípios atualizados.',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export type LeadershipListMunicipalitiesResponse =
|
||||
| {
|
||||
status: 'success'
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
status: 'error'
|
||||
message: string
|
||||
}
|
||||
|
|
@ -7,10 +7,12 @@ import { getPayload } from 'payload'
|
|||
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
|
||||
import { LeadershipFilters } from '@/components/campaign/leadership/LeadershipFilters'
|
||||
import { LeadershipListSupportStatusControl } from '@/components/campaign/leadership/LeadershipListSupportStatusControl'
|
||||
import { LeadershipMunicipalitiesColumnCell } from '@/components/campaign/leadership/LeadershipMunicipalitiesColumnCell'
|
||||
import {
|
||||
LeadershipFilterHead,
|
||||
LeadershipSortableHead,
|
||||
} from '@/components/campaign/leadership/LeadershipSortableHead'
|
||||
import { LeadershipStateDeputiesColumnCell } from '@/components/campaign/leadership/LeadershipStateDeputiesColumnCell'
|
||||
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
|
||||
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
|
|
@ -25,11 +27,7 @@ import {
|
|||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import {
|
||||
LeadershipStateDeputyRelationCell,
|
||||
type RelationCellOption,
|
||||
} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
|
||||
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
|
||||
import type { RelationCellOption } from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
|
|
@ -79,11 +77,7 @@ import { loadOrganizationNamesByIds } from '@/utilities/loadNamesByIds'
|
|||
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
|
||||
import { loadStateDeputySummaries } from '@/utilities/stateDeputyData'
|
||||
|
||||
import {
|
||||
setLeadershipMunicipalitiesFormAction,
|
||||
setLeadershipStateDeputyMembershipFormAction,
|
||||
updateLeadershipContactFormAction,
|
||||
} from './formActions'
|
||||
import { updateLeadershipContactFormAction } from './formActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('liderancas')
|
||||
|
||||
|
|
@ -229,7 +223,7 @@ const leadershipColumns = ({
|
|||
),
|
||||
cellClassName: 'max-w-64 whitespace-normal',
|
||||
cell: (row) => (
|
||||
<MunicipalityPortfolioCell
|
||||
<LeadershipMunicipalitiesColumnCell
|
||||
ownerId={row.id}
|
||||
ownerName={row.name}
|
||||
municipalityIds={row.municipalityIDs}
|
||||
|
|
@ -237,7 +231,6 @@ const leadershipColumns = ({
|
|||
{...(addableMunicipalityIds ? { addableIds: addableMunicipalityIds } : {})}
|
||||
minItems={1}
|
||||
maxItems={MAX_LEADERSHIP_MUNICIPALITIES}
|
||||
commitAction={setLeadershipMunicipalitiesFormAction}
|
||||
drawerTitle="Municípios da liderança"
|
||||
updateErrorMessage="Não foi possível atualizar os municípios."
|
||||
/>
|
||||
|
|
@ -254,7 +247,7 @@ const leadershipColumns = ({
|
|||
label: 'Dobradinhas',
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
cell: (row) => (
|
||||
<LeadershipStateDeputyRelationCell
|
||||
<LeadershipStateDeputiesColumnCell
|
||||
direction="fromLeadership"
|
||||
fixedId={row.id}
|
||||
ownerName={row.name}
|
||||
|
|
@ -265,7 +258,6 @@ const leadershipColumns = ({
|
|||
...(deputy.party ? { party: deputy.party } : {}),
|
||||
}))}
|
||||
options={stateDeputyOptions}
|
||||
membershipAction={setLeadershipStateDeputyMembershipFormAction}
|
||||
measureOverflow={false}
|
||||
/>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages'
|
||||
import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import {
|
||||
LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
leadershipStateDeputyMembershipSchema,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
|
||||
|
||||
import type { LeadershipListStateDeputiesResponse } from './types'
|
||||
|
||||
export type { LeadershipListStateDeputiesResponse } from './types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const POST = campaignJsonMutationRoute(
|
||||
{
|
||||
bodySchema: leadershipStateDeputyMembershipSchema,
|
||||
safeMessages: [...leadershipStaffEditSafeMessages, LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE],
|
||||
genericMessage: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
|
||||
},
|
||||
async (body) => {
|
||||
await setLeadershipStateDeputyMembership(body)
|
||||
|
||||
return NextResponse.json<LeadershipListStateDeputiesResponse>({
|
||||
status: 'success',
|
||||
message: 'Dobradinhas atualizadas.',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export type LeadershipListStateDeputiesResponse =
|
||||
| {
|
||||
status: 'success'
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
status: 'error'
|
||||
message: string
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, type ComponentProps } from 'react'
|
||||
|
||||
import type { LeadershipListMunicipalitiesResponse } from '@/app/(campaign)/campanha/(app)/liderancas/municipalities/types'
|
||||
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
const ENDPOINT = '/campanha/liderancas/municipalities'
|
||||
const GENERIC_ERROR = 'Não foi possível atualizar os municípios. Tente novamente.'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof MunicipalityPortfolioCell>, 'commitAction'>
|
||||
|
||||
/**
|
||||
* Thin adapter (B155+): wraps `MunicipalityPortfolioCell` with a route-based
|
||||
* `commitAction` that posts JSON to `POST /campanha/liderancas/municipalities`
|
||||
* — the cell sends `ownerId`, which is the leadership id on this call site.
|
||||
*/
|
||||
export const LeadershipMunicipalitiesColumnCell = (props: Props) => {
|
||||
const commitAction = useMemo(
|
||||
() =>
|
||||
async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> => {
|
||||
const leadershipId = Number(formData.get('ownerId'))
|
||||
const assigned = formData.get('assigned') === 'true'
|
||||
const municipalityIds = formData.getAll('municipalityIds').map(Number)
|
||||
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<LeadershipListMunicipalitiesResponse>(
|
||||
ENDPOINT,
|
||||
{ leadershipId, municipalityIds, assigned },
|
||||
)
|
||||
|
||||
if (!ok || payload.status !== 'success') {
|
||||
return {
|
||||
message: payload.status === 'error' ? payload.message : GENERIC_ERROR,
|
||||
}
|
||||
}
|
||||
return { status: 'success' as const, message: payload.message }
|
||||
} catch {
|
||||
return { message: GENERIC_ERROR }
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return <MunicipalityPortfolioCell {...props} commitAction={commitAction} />
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, type ComponentProps } from 'react'
|
||||
|
||||
import type { LeadershipListStateDeputiesResponse } from '@/app/(campaign)/campanha/(app)/liderancas/state-deputies/types'
|
||||
import { LeadershipStateDeputyRelationCell } from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
const ENDPOINT = '/campanha/liderancas/state-deputies'
|
||||
const GENERIC_ERROR = 'Não foi possível atualizar as dobradinhas. Tente novamente.'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof LeadershipStateDeputyRelationCell>, 'membershipAction'>
|
||||
|
||||
/**
|
||||
* Thin adapter (B155+): wraps `LeadershipStateDeputyRelationCell` with a
|
||||
* route-based `membershipAction` that posts JSON to
|
||||
* `POST /campanha/liderancas/state-deputies` — same `(state, formData)`
|
||||
* contract the cell already calls, transport swapped from server action.
|
||||
*/
|
||||
export const LeadershipStateDeputiesColumnCell = (props: Props) => {
|
||||
const membershipAction = useMemo(
|
||||
() =>
|
||||
async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> => {
|
||||
const leadershipId = Number(formData.get('leadershipId'))
|
||||
const stateDeputyId = Number(formData.get('stateDeputyId'))
|
||||
const assigned = formData.get('assigned') === 'true'
|
||||
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<LeadershipListStateDeputiesResponse>(
|
||||
ENDPOINT,
|
||||
{ leadershipId, stateDeputyId, assigned },
|
||||
)
|
||||
|
||||
if (!ok || payload.status !== 'success') {
|
||||
return {
|
||||
message: payload.status === 'error' ? payload.message : GENERIC_ERROR,
|
||||
}
|
||||
}
|
||||
return { status: 'success' as const, message: payload.message }
|
||||
} catch {
|
||||
return { message: GENERIC_ERROR }
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return <LeadershipStateDeputyRelationCell {...props} membershipAction={membershipAction} />
|
||||
}
|
||||
|
|
@ -97,9 +97,9 @@ test.describe('campaign leaderships list', () => {
|
|||
page.getByRole('button', { name: new RegExp(`não é possível remover ${linked.name}$`) }),
|
||||
).toBeDisabled()
|
||||
|
||||
// Server actions POST to the page URL, so the response filter is the route
|
||||
// itself — the optimistic chip renders before the write lands.
|
||||
const persisted = () => expectPostResponse(page, '/campanha/liderancas')
|
||||
// The chip cell posts JSON to its mutation route — the optimistic chip
|
||||
// renders before the write lands.
|
||||
const persisted = () => expectPostResponse(page, '/campanha/liderancas/municipalities')
|
||||
|
||||
const search = page.getByRole('combobox', {
|
||||
name: 'Buscar município, território de identidade ou zona eleitoral',
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
|
|||
'/campanha/municipios/political-trend',
|
||||
'/campanha/municipios/next-steps',
|
||||
'/campanha/liderancas/support-status',
|
||||
'/campanha/liderancas/state-deputies',
|
||||
'/campanha/liderancas/municipalities',
|
||||
'/campanha/home-search',
|
||||
// The Sollinha chat mounts on every authenticated page: its transport and
|
||||
// the PWA registration hit these on first load/session-restore — cold
|
||||
|
|
|
|||
|
|
@ -20,7 +20,14 @@ describe('shared sheet host for chip-relation cells (miss #52)', () => {
|
|||
* table (their owning page holds the provider — checked for the C116 people
|
||||
* list like the dobradinhas one).
|
||||
*/
|
||||
const cellWrapperFiles = new Set(['src/components/campaign/people/PeopleMunicipalityCell.tsx'])
|
||||
const cellWrapperFiles = new Set([
|
||||
// C128 people list; C116 batch-chip expansion state — the owning page
|
||||
// holds the provider.
|
||||
'src/components/campaign/people/PeopleMunicipalityCell.tsx',
|
||||
// B155+ route-backed column adapters — the owning pages hold the provider.
|
||||
'src/components/campaign/leadership/LeadershipMunicipalitiesColumnCell.tsx',
|
||||
'src/components/campaign/leadership/LeadershipStateDeputiesColumnCell.tsx',
|
||||
])
|
||||
|
||||
it('wraps every chip-relation table in CampaignListSheetProvider', () => {
|
||||
const offenders: string[] = []
|
||||
|
|
|
|||
Loading…
Reference in a new issue