mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
fix(campanha): restaura submitter no FormData do card de demanda (#921)
O dispatch manual do C139 montava new FormData(form) sem o submitter; os botões de transição (name=status value=aprovada|rejeitada|…) nunca viajavam e transitionDemandFormAction rejeitava toda decisão por status vazio. submitterFrom (cast honesto nativeEvent as SubmitEvent) + segundo arg do construtor nos handlers de transição e custo; C139 intacto. Teste unit novo pina o glue FormData→status (SubmitEvent real, não fireEvent.submit). E2e 'advisor opens a demand and decides it' verde local (isolado e em runs de arquivo inteiro).
This commit is contained in:
parent
053d1e975c
commit
4d932efcb3
4 changed files with 204 additions and 3 deletions
19
docs/changelog/2026-08-25-e2e-demand-decide.md
Normal file
19
docs/changelog/2026-08-25-e2e-demand-decide.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# 2026-08-25 — E2E-DEMAND-DECIDE: decisão de demanda volta a gravar (submeter perdido na regressão C139)
|
||||
|
||||
A decisão de demanda em `/campanha` parou de gravar desde o react-audit família 4 (e273e9ec):
|
||||
o dispatch manual do C139 montava `new FormData(form)` sem o submitter, e os botões de
|
||||
transição (`name="status" value={target}`) nunca viajavam — `transitionDemandFormAction`
|
||||
lia `status` vazio e toda decisão caía em erro de validação. 3 deploys manuais falharam no
|
||||
e2e full do verify ("advisor opens a demand and decides it").
|
||||
|
||||
Correção local no único formulário do padrão com botões nomeados (`DemandWorkflowCard.tsx`):
|
||||
helper `submitterFrom` (cast honesto de `nativeEvent as SubmitEvent`, comentado) e
|
||||
`new FormData(form, submitter)` nos handlers de transição e custo (no-op de segurança no
|
||||
custo — botão sem name/value hoje). C139 intacto: `preventDefault` + `startTransition` +
|
||||
`useActionState` preservados, nota digitada não some em erro de validação.
|
||||
|
||||
Teste novo `tests/unit/demandWorkflowCard.unit.spec.tsx` pina o glue FormData→status
|
||||
(vermelho pré-fix, verde pós-fix): `SubmitEvent` real em vez de `fireEvent.submit`
|
||||
(testing-library mapeia submit para `Event` simples, sem submitter). E2E determinístico
|
||||
verde local (isolado via filtro posicional `spec.ts:764` e 3× em runs de arquivo inteiro).
|
||||
Descoberta de tooling registrada como #923 (`--no-deps` + `-g` não filtra em run mode).
|
||||
73
docs/plans/desbloquear-deploy-decisao-demanda-impl.md
Normal file
73
docs/plans/desbloquear-deploy-decisao-demanda-impl.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Impl: Desbloquear deploy: decisão de demanda em /campanha parou de gravar (regressão pós-react-audit)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-25
|
||||
Issue: #921
|
||||
Intenção: docs/plans/desbloquear-deploy-decisao-demanda.md
|
||||
Appetite restante: ~0,25–0,5 dia (fix + teste unit + e2e local; deploy é do humano pós-merge)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** decidir demanda volta a gravar — nota + Aprovar/Rejeitar persistem e a tela exibe "Esta demanda já foi decidida."; e2e `tests/e2e/campaignMunicipalities.e2e.spec.ts:764` verde local e no verify; deploy manual verde concluído (humano, pós-merge).
|
||||
- **O que NÃO negociar:** C139 intacto (dispatch manual `startTransition`+`useActionState` em todos os formulários — nota digitada preservada em erro de validação); nenhum outro formulário muda; nada de tocar retries/workers do e2e; nenhum skip/remoção de teste; sem migration, sem Consent, sem UI.
|
||||
- **O que reavaliar:** nada de produto. Em engenharia: uniformizar o submitter no formulário de custo (questão a) e adicionar teste unit do glue (questão b) — decidido abaixo.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Botão Aprovar/Rejeitar (type=submit, name=status, value=target)"] --> B["handleTransitionSubmit (DemandWorkflowCard.tsx:70)"]
|
||||
B --> C["new FormData(event.currentTarget, (event.nativeEvent as SubmitEvent).submitter)"]
|
||||
C --> D["transitionDemandFormAction lê requiredFormText(formData, 'status') — presente"]
|
||||
D --> E["transitionCampaignDemand grava + revalidatePath"]
|
||||
E --> F["Botões somem; 'Esta demanda já foi decidida.' (linha 157)"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (FormData com submitter) | B (status via refs/state) | C (hidden input sincronizado)
|
||||
**Recomendação:** **Fix A** — o construtor `new FormData(form, submitter)` do WHATWG anexa os pares name/value do botão submetido; é a correção mínima no ponto exato da regressão, sem novo estado e sem tocar no dispatch manual do C139.
|
||||
**Rejeitadas:** B (mais código, zero benefício); C (mais estado, nova classe de bug); reverter e273e9ec (perde C139); converter ~20 formulários (explosão de escopo); tornar `status` opcional na server action (enfraquece validação).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`DemandWorkflowCard.tsx`** (`src/components/campaign/demand/DemandWorkflowCard.tsx`): único arquivo de produto alterado. Extrair helper de módulo `submitterFrom(event: FormEvent<HTMLFormElement>): HTMLElement | null` que devolve `(event.nativeEvent as SubmitEvent).submitter` (cast necessário: React tipa `FormEvent<T>` como `SyntheticEvent<T, Event>` → `nativeEvent: Event`, mas o evento nativo real de submit é sempre `SubmitEvent`, cujo `.submitter` é `HTMLElement | null`). Usar em `handleTransitionSubmit` (linhas 70–73) e `handleCostSubmit` (linhas 74–77): `new FormData(event.currentTarget, submitterFrom(event))`. O `Button` de `src/components/ui/button.tsx` repassa `...props` ao `<button>` nativo, então `name`/`value` chegam ao DOM e o submitter passa a viajar no FormData. No form de custo o botão não tem `name`/`value` — spec: submitter sem name não anexa nada → no-op hoje, imunidade para o futuro. Form de comprovante (`action={submitReceipt}`) intocado. C139 preservado: `event.preventDefault()` + `startTransition(() => submit...)` não mudam; nenhum `action=` é reintroduzido.
|
||||
- **Teste unit novo** (`tests/unit/demandWorkflowCard.unit.spec.tsx`): glue test que renderiza o card com `status="em_analise"`, `demandID={1}` e uma `transitionFormAction` fake que captura o FormData; preenche a nota e dispara `fireEvent.submit(form, { submitter: buttonAprovar })`. Asserts: `captured.get('status') === 'aprovada'`, `captured.get('decisionNote')` preservada, `captured.get('demandId') === '1'`. Pré-fix o teste falha (`status` nulo) — é a rede de regressão do glue no PR CI (o spec e2e não roda no CI de PR: cai no fallback smoke do `scripts/lib/e2e-affected-manifest.mjs`). Roda no unit config (`vitest.unit.config.mts`: jsdom, include `tests/unit/**/*.unit.spec.{ts,tsx}`; sem DB, sem payload). Convenções: plain vitest + testing-library, padrão de `campaignFormActionMessage.unit.spec.tsx`.
|
||||
- **Migration:** sem migration
|
||||
- **Access / Consent:** N/A — a server action `transitionDemandFormAction` e sua RBAC não mudam
|
||||
- **UI:** Impeccable A — sem UI nova
|
||||
- **Changelog:** `docs/changelog/2026-08-25-e2e-demand-decide.md` — UMA entrada curta (padrão AGENTS); nunca editar o agregado.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Fix do card:** helper `submitterFrom` + os dois handlers usando-o. `pnpm gate:fast` verde.
|
||||
2. **Teste unit do glue:** `tests/unit/demandWorkflowCard.unit.spec.tsx`; provar que falha sem o fix (estado atual) e passa com ele. `pnpm test:unit` e `pnpm gate:fast` verdes.
|
||||
3. **Gates** — `pnpm gate:fast`; `pnpm test:int` (camada de registro não muda — rodar por garantia, barato); e2e local **obrigatório antes do push** (política OPS72): `pnpm test:e2e --no-deps -- tests/e2e/campaignMunicipalities.e2e.spec.ts -g "advisor opens a demand and decides it"` (usar `--workers=1` se colidir no `seedTestUser`; `E2E_PROD=1` espelha o verify). Depois `pnpm push`, PR com `Closes #921`, base `main`, auto-merge armado (convenção repo, check-run `checks` é o gate). Main verde encerra a entrega da sessão; o deploy manual (aceite 3) é do humano pós-merge: verify verde **e** job deploy concluído (conferir runner self-hosted online antes — falha "job was not acquired" é infra, fora deste item).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não reverter e273e9ec nem a família 4 — perde C139.
|
||||
- Não converter os outros ~20 formulários do padrão — só o DemandWorkflowCard tem botões nomeados (sweep de 27 arquivos; `ActivityOverlay.tsx:445` tem INPUT hidden `name="status"`, não botão — intocado).
|
||||
- Não tornar `status` opcional na server action; não trocar `requiredFormText` por `formData.get` — validação atual é correta.
|
||||
- Não corrigir o manifest de e2e afetados nem a família de flakes #882 — itens próprios.
|
||||
- Não adicionar teste int do glue: o glue é cliente (`FormData` é montado no browser); a camada de registro já tem `tests/int/campaignDemandWorkflow.int.spec.ts`. Um teste unit da server action exigiria payload/DB — fora da camada unit.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Cast `nativeEvent as SubmitEvent`:** evento de submit real é sempre `SubmitEvent` (spec HTML); React 19 entrega o evento nativo em `nativeEvent`. Risco residual baixíssimo; o e2e determinístico (browser real) é a prova final no verify.
|
||||
- **C139 regredir:** o dispatch manual e o `preventDefault` não mudam — só a construção do FormData. O teste unit novo asserta `decisionNote` presente no FormData capturado, e o e2e cobre o fluxo completo (nota digitada + decisão + "Esta demanda já foi decidida.").
|
||||
- **jsdom vs browser no submitter:** jsdom 28 implementa `FormData(form, submitter)` com validação igual à spec — o teste unit usa botão submit real dentro do form, sem fakes de DOM.
|
||||
- **Custo do fix no form de custo:** no-op garantido (botão sem `name`/`value`); mudança é só uniformidade do padrão.
|
||||
|
||||
## Decisões de engenharia
|
||||
|
||||
- **Fix (A | B | C):** Opções: A — `new FormData(form, submitter)`; B — ler status via refs/state; C — hidden input sincronizado com último clique. **Recomendação:** A. **Rejeitadas:** B e C (mais estado/código sem benefício); revert do commit; conversão em massa; server action permissiva.
|
||||
- **Questão (a) — submitter no `handleCostSubmit`:** Opções: incluir agora | deixar como está. **Recomendação:** incluir — mesma linha de código via helper `submitterFrom`, previne a classe de bug sem mudança de comportamento. **Rejeitada:** deixar como está (a próxima pessoa que nomear um botão no form de custo reproduz o bug).
|
||||
- **Questão (b) — teste unit do glue:** Opções: exigir | opcional | não. **Recomendação:** exigir — é o único teste do glue no PR CI (o spec e2e não roda lá), custa ~30 min, falha sem o fix e passa com ele (prova de valor real). **Rejeitados:** testar `new FormData` puro em jsdom (testa a lib, não o nosso código); testar `transitionDemandFormAction` em unit (exige payload/DB — camada errada).
|
||||
- **Helper local vs inline nos dois handlers:** **Recomendação:** helper de módulo de 2 linhas no próprio `DemandWorkflowCard.tsx` (cast concentrado num ponto). **Rejeitado:** utilitário compartilhado em `src/lib/` — 2 call sites, não justifica superfície pública.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] `handleTransitionSubmit` e `handleCostSubmit` montam o FormData com o submitter (`submitterFrom` + cast `SubmitEvent`); C139 intacto (nenhum `action=` reintroduzido, `startTransition` preservado)
|
||||
- [ ] `tests/unit/demandWorkflowCard.unit.spec.tsx` verde (e comprovadamente vermelho pré-fix); `pnpm gate:fast` verde
|
||||
- [ ] e2e local verde: `pnpm test:e2e --no-deps -- tests/e2e/campaignMunicipalities.e2e.spec.ts -g "advisor opens a demand and decides it"`
|
||||
- [ ] Entrada curta em `docs/changelog/2026-08-25-e2e-demand-decide.md`; PR `Closes #921` base `main` com auto-merge armado; main verde
|
||||
- [ ] (humano, pós-merge) deploy manual: verify verde e job deploy concluído — critério final da Issue
|
||||
|
|
@ -38,6 +38,13 @@ type DemandWorkflowCardProps = {
|
|||
const transitionVariant = (target: CampaignDemandStatus) =>
|
||||
target === 'aprovada' ? 'default' : target === 'rejeitada' ? 'destructive' : 'secondary'
|
||||
|
||||
/**
|
||||
* The native submit event always carries the pressed submit button; React
|
||||
* types `FormEvent.nativeEvent` as a plain `Event`, so the read is a cast.
|
||||
*/
|
||||
const submitterFrom = (event: FormEvent<HTMLFormElement>): HTMLElement | null =>
|
||||
(event.nativeEvent as SubmitEvent).submitter
|
||||
|
||||
/** Staff-only workflow: analysis, escalation, decision, cost and receipts. */
|
||||
export const DemandWorkflowCard = ({
|
||||
demandID,
|
||||
|
|
@ -66,14 +73,16 @@ export const DemandWorkflowCard = ({
|
|||
// C139 — manual dispatch (no `action={submitTransition}`/`action={submitCost}`):
|
||||
// React 19 resets uncontrolled fields after any settled form action, wiping
|
||||
// the typed decision note and cost on a validation error. `startTransition`
|
||||
// keeps the pending flags correct for the imperative dispatch.
|
||||
// keeps the pending flags correct for the imperative dispatch. The FormData
|
||||
// constructor receives the submitter (#921): the decision buttons carry
|
||||
// `name="status" value={target}` and only the pressed one must travel.
|
||||
const handleTransitionSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
startTransition(() => submitTransition(new FormData(event.currentTarget)))
|
||||
startTransition(() => submitTransition(new FormData(event.currentTarget, submitterFrom(event))))
|
||||
}
|
||||
const handleCostSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
startTransition(() => submitCost(new FormData(event.currentTarget)))
|
||||
startTransition(() => submitCost(new FormData(event.currentTarget, submitterFrom(event))))
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
|
|
|
|||
100
tests/unit/demandWorkflowCard.unit.spec.tsx
Normal file
100
tests/unit/demandWorkflowCard.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { DemandWorkflowCard } from '@/components/campaign/demand/DemandWorkflowCard'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
/**
|
||||
* #921 glue regression net: the C139 manual dispatch builds FormData from the
|
||||
* form element only, so the pressed transition button (name="status",
|
||||
* value="aprovada"|…) never reaches `transitionDemandFormAction` and every
|
||||
* decision fails validation. The dispatch must include the submitter.
|
||||
*/
|
||||
describe('DemandWorkflowCard transition submit', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it('includes the pressed decision button (name=status) in the submitted FormData', () => {
|
||||
let captured: FormData | null = null
|
||||
const captureTransition = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> => {
|
||||
captured = formData
|
||||
return {}
|
||||
}
|
||||
|
||||
render(
|
||||
<DemandWorkflowCard
|
||||
demandID={7}
|
||||
status="em_analise"
|
||||
canDecideEscalated={false}
|
||||
currentCost={null}
|
||||
transitionFormAction={captureTransition}
|
||||
costFormAction={async () => ({})}
|
||||
receiptFormAction={async () => ({})}
|
||||
/>,
|
||||
)
|
||||
|
||||
const approveButton = screen.getByRole('button', { name: 'Aprovar' })
|
||||
const form = approveButton.closest('form')
|
||||
expect(form).not.toBeNull()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Nota da decisão'), {
|
||||
target: { value: 'Autorizado pelo assessor.' },
|
||||
})
|
||||
|
||||
// A real SubmitEvent, not `fireEvent.submit`: testing-library maps submit
|
||||
// to a plain Event, which cannot carry the submitter this test pins.
|
||||
form!.dispatchEvent(
|
||||
new SubmitEvent('submit', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
submitter: approveButton,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(captured).not.toBeNull()
|
||||
expect(captured!.get('status')).toBe('aprovada')
|
||||
expect(captured!.get('decisionNote')).toBe('Autorizado pelo assessor.')
|
||||
expect(captured!.get('demandId')).toBe('7')
|
||||
})
|
||||
|
||||
it('submits the cost form with the cost button as submitter', () => {
|
||||
let captured: FormData | null = null
|
||||
const captureCost = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> => {
|
||||
captured = formData
|
||||
return {}
|
||||
}
|
||||
|
||||
render(
|
||||
<DemandWorkflowCard
|
||||
demandID={7}
|
||||
status="em_analise"
|
||||
canDecideEscalated={false}
|
||||
currentCost={1200}
|
||||
transitionFormAction={async () => ({})}
|
||||
costFormAction={captureCost}
|
||||
receiptFormAction={async () => ({})}
|
||||
/>,
|
||||
)
|
||||
|
||||
const costButton = screen.getByRole('button', { name: 'Salvar custo' })
|
||||
const form = costButton.closest('form')
|
||||
expect(form).not.toBeNull()
|
||||
|
||||
form!.dispatchEvent(
|
||||
new SubmitEvent('submit', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
submitter: costButton,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(captured).not.toBeNull()
|
||||
expect(captured!.get('cost')).toBe('1200')
|
||||
expect(captured!.get('demandId')).toBe('7')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue