diff --git a/docs/plans/quick-edit-sheet-mobile-quebrado-impl.md b/docs/plans/quick-edit-sheet-mobile-quebrado-impl.md new file mode 100644 index 00000000..95fd6351 --- /dev/null +++ b/docs/plans/quick-edit-sheet-mobile-quebrado-impl.md @@ -0,0 +1,62 @@ +# Impl: Quick-edit sheet mobile da lista de municípios quebra ao abrir (DialogClose fora do contexto Drawer) + +Status: aprovado +Atualizado em: 2026-08-10 +Issue: #540 (C109) +Intenção: docs/plans/quick-edit-sheet-mobile-quebrado.md +Appetite restante: herdado (~2–4 h eng) + +## Leitura da intenção + +- **Outcome:** no celular, tocar em "Registrar atualização" (ou qualquer quick-edit sheet com footer custom) no card de município abre o sheet e registra, sem console error. +- **O que NÃO negociar:** popover desktop e fallback sem provider intactos; sem mudança de access/Consent/URL; affordance "Cancelar" no rodapé preservado (decisão B32). +- **O que reavaliar:** a hipótese da intenção (portal preserva contexto da origem). **Confirmada no código**: `CampaignCellEditOverlay` portaliza `footer` (`createPortal(footer, footerTarget)` em `CampaignCellEditOverlay.tsx:207`) para o `DrawerFooter` do host (`CampaignListSheetHost.tsx:155`); `createPortal` resolve contexto React da árvore de ORIGEM (a célula, sem `Drawer.Root`), então `DrawerCloseButton` (base-ui `DialogClose`) explode em `useDialogRootContext()`. O fallback sem provider renderiza o footer inline (contexto OK) — por isso o unit test atual passa. + +## Abordagem recomendada + +```mermaid +flowchart LR + Cell["Célula (MunicipalityListUpdateControl / LevelControl)"] -->|openSheet chrome {title, description, footer}| Host["CampaignListSheetHost (Drawer.Root)"] + Cell -->|footer JSX criado aqui, estado do form vive aqui| Host + Host -->|renderiza chrome.footer inline no DrawerFooter| Footer["submit + Cancelar
(DrawerClose resolve contexto do Drawer.Root)"] + Cell -->|body portalado (mantido)| Body["body no DrawerContent"] +``` + +**Opções consideradas:** A) footer vira parte do `chrome` do shared-sheet (mesmo mecanismo de title/description); a célula passa o footer no `openSheet`, o host renderiza dentro do `DrawerFooter` — portal do footer removido | B) envolver o footer portalado num wrapper de provider do Drawer (hack) | C) mover `DrawerCloseButton` para fora do footer custom (perder "Cancelar"). +**Recomendação:** A — o ReactNode do footer é um elemento criado na célula (estado do form, `useActionState`, `isPending` continuam vivendo lá; a associação `form` attribute é DOM-level e não precisa de contexto), mas é **renderizado** na árvore do host, onde o `Drawer.Root` existe — contexto correto por construção, alinhado ao design existente do chrome, e remove o portal do footer por completo (sem timing de `portalRevision` para o footer). +**Rejeitadas:** B porque mascara o defeito com contexto duplicado e o primeiro crash só muda de endereço; C porque regride o affordance decidido em B32 (submit + Cancelar no rodapé de sheet com body rolável). + +### Componentes / mudanças + +- **`CampaignListSheetChrome`** (`src/components/campaign/shared/CampaignListSheetHost.tsx`): troca `hasCustomFooter: boolean` por `footer?: ReactNode` (derivado: `Boolean(chrome.footer)`). `DrawerFooter` passa a renderizar `{chrome.footer ?? Fechar}` — o div de portal (`ref={attachFooterPortal}`) sai; `footerPortalRef` e `attachFooterPortal` saem do contexto e do provider (`portalRevision` permanece, servindo o body). `openSheet` inclui `current.footer === next.footer` na comparação de memo (na prática a célula sempre cria JSX fresco — comportamento equivalente ao de hoje, onde `handleOpenChange` da LevelControl já quebra o memo). +- **`CampaignCellEditOverlay.tsx`** (path shared, `variant === 'sheet' && sharedSheet`): para de portalar o footer — `openSheet({ ..., footer, ... })`; render retorna só trigger + liveRegion + body portalado. Path fallback (sem provider) e popover intactos. +- **Call sites** (`MunicipalityListUpdateControl.tsx:83`, `MunicipalityListLevelControl.tsx:231`): **zero mudança de props** — o footer continua sendo prop do overlay; os dois são os únicos call sites com footer custom no repo (verificado por grep), e os outros consumidores do provider (AdvisorsTable, dobradinhas, lideranças, trend/expected/leaderships/assessores) usam o `Fechar` default que o host já renderiza inline — não afetados. +- **Migration:** sem migration (UI only). +- **Access / Consent:** nenhum. +- **UI:** Impeccable A — defect de render, restaura comportamento existente, sem redesenho. + +## Fases verificáveis + +1. **Root cause em teste** — unit test do shared path (provider + célula) que falha em `main` (crash do `DialogClose`) e passa com o fix: `tests/unit/campaignCellEditOverlay.unit.spec.ts` ganha describe do shared path cobrindo `MunicipalityListUpdateControl` e `MunicipalityListLevelControl` (abre → footer com submit + "Cancelar" → fecha via "Cancelar" sem crash). +2. **Fix no host/overlay** — chrome com footer; overlay sem portal; remoção de `footerPortalRef`/`hasCustomFooter`. +3. **Gates** — `pnpm gate:fast`; e2e mobile: novo teste no describe B42 (`tests/e2e/campaignMunicipalities.e2e.spec.ts`) abre o sheet "Registrar atualização" no card (dialog visível, footer com "Registrar atualização" + "Cancelar", fecha) — regressão que vermelha em main; e2e existente do popover intacto. +4. **Verificação manual no browser** — abrir/registrar/fechar os dois sheets custom no viewport mobile. + +## Rabbit holes / Não escopo (engenharia) + +- Redesenhar sheet/footer dos quick-edits. +- Outros drawers/shared-sheets (agenda, convites, notificações) — mecanismo próprio, fora. +- Refatorar a comparação de memo do `openSheet` (comportamento já não-detectável hoje). + +## Riscos e mitigação + +- **E14 e B42 compartilham o path** — o fix cobre ambos; unit + e2e cobrem os dois call sites. +- **Reabertura do sheet** — `dismissSheet` zera o chrome e o próximo `openSheet` recria o footer fresco; unit test cobre abrir→fechar→reabrir. +- **Footer stale no host** — o efeito do overlay re-envia o chrome a cada re-render da célula (dep `footer` já existe), então `isPending`/spinner chegam ao host; `useLayoutEffect` roda antes do paint. +- **Contexto do form** — o submit do footer continua associado pelo `form` attribute (DOM), como hoje; unit F5 existente garante a associação no path fallback. + +## Aceite de engenharia + +- [ ] Quick-edit sheet com footer custom abre no shared path sem console error, e "Cancelar" fecha (unit + e2e mobile novos) +- [ ] Popover desktop e fallback sem provider intactos (unit existente + e2e existentes verdes) +- [ ] Invariantes: sem migration, sem access/Consent, sem mudança de props nos call sites diff --git a/src/components/campaign/shared/CampaignCellEditOverlay.tsx b/src/components/campaign/shared/CampaignCellEditOverlay.tsx index af41734c..a0f6cf68 100644 --- a/src/components/campaign/shared/CampaignCellEditOverlay.tsx +++ b/src/components/campaign/shared/CampaignCellEditOverlay.tsx @@ -186,7 +186,7 @@ export const CampaignCellEditOverlay = ({ sharedSheet.openSheet({ title, description, - hasCustomFooter: Boolean(footer), + footer, sheetBodyClassName, onOpenChange, }) @@ -195,8 +195,7 @@ export const CampaignCellEditOverlay = ({ if (variant === 'sheet' && sharedSheet) { const showPortals = open && sharedSheet.isActiveSheet(onOpenChange) const bodyTarget = showPortals ? sharedSheet.bodyPortalRef.current : null - const footerTarget = showPortals ? sharedSheet.footerPortalRef.current : null - // portalRevision keeps this render in sync after the drawer mounts its targets. + // portalRevision keeps this render in sync after the drawer mounts its target. void sharedSheet.portalRevision return ( @@ -204,7 +203,6 @@ export const CampaignCellEditOverlay = ({ {triggerButton} {liveRegion} {showPortals && bodyTarget ? createPortal(children, bodyTarget) : null} - {showPortals && footerTarget && footer ? createPortal(footer, footerTarget) : null} ) } diff --git a/src/components/campaign/shared/CampaignListSheetHost.tsx b/src/components/campaign/shared/CampaignListSheetHost.tsx index 2d882e7a..92c2b0ae 100644 --- a/src/components/campaign/shared/CampaignListSheetHost.tsx +++ b/src/components/campaign/shared/CampaignListSheetHost.tsx @@ -25,15 +25,25 @@ import { cn } from '@/lib/utils' export type CampaignListSheetChrome = { title: string description?: string - hasCustomFooter: boolean + /** + * Custom footer for a sheet whose commit is an explicit submit (E14's + * "Registrar movimento", B42's "Registrar atualização"). Rendered INLINE in + * the host's DrawerFooter so its controls resolve the Drawer's context — + * the cell's own tree has no `Drawer.Root`, and a portaled footer keeps the + * source context and crashes base-ui's `DialogClose` (C109). The element is + * still CREATED by the cell (form state, `form` attribute association live + * there); only its render location moves — so it can rely on props/state + * captured by the cell and on providers ABOVE the sheet provider, never on + * providers local to the cell. + */ + footer?: ReactNode sheetBodyClassName?: string onOpenChange: (open: boolean) => void } type CampaignListSheetContextValue = { bodyPortalRef: RefObject - footerPortalRef: RefObject - /** Bumps when the drawer body/footer mount targets attach — portals read this. */ + /** Bumps when the drawer body mount target attaches — portals read this. */ portalRevision: number openSheet: (chrome: CampaignListSheetChrome) => void dismissSheet: (onOpenChange: (open: boolean) => void) => void @@ -47,12 +57,13 @@ export const useCampaignListSheet = () => useContext(CampaignListSheetContext) /** * One bottom Drawer for an entire list surface (B42 mobile cards). Cell bodies * portal into it so we never mount hundreds of Drawer roots and never sync form - * trees through provider state (which tripped React #185 in prod). + * trees through provider state (which tripped React #185 in prod). Custom + * footers travel in the chrome instead of portaling: a portaled footer keeps + * the cell's context, which has no `Drawer.Root`, and crashes (C109). */ export const CampaignListSheetProvider = ({ children }: { children: ReactNode }) => { const titleRef = useRef(null) const bodyPortalRef = useRef(null) - const footerPortalRef = useRef(null) const [open, setOpen] = useState(false) const [chrome, setChrome] = useState(null) const [portalRevision, setPortalRevision] = useState(0) @@ -63,11 +74,6 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode }) if (node) setPortalRevision((value) => value + 1) }, []) - const attachFooterPortal = useCallback((node: HTMLDivElement | null) => { - footerPortalRef.current = node - if (node) setPortalRevision((value) => value + 1) - }, []) - const dismissSheet = useCallback((onOpenChange: (open: boolean) => void) => { if (activeOnOpenChangeRef.current !== onOpenChange) return setOpen(false) @@ -89,7 +95,11 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode }) current.title === next.title && current.description === next.description && current.sheetBodyClassName === next.sheetBodyClassName && - current.hasCustomFooter === next.hasCustomFooter + // Deliberately NOT `hasCustomFooter`: a custom footer is a fresh JSX + // element on every cell render, and this comparison is what forces the + // host to adopt it — `isPending`/spinner freshness depends on the + // footer being re-rendered here (the cell no longer renders it itself). + current.footer === next.footer ) { return current } @@ -121,7 +131,6 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode }) const contextValue = useMemo( () => ({ bodyPortalRef, - footerPortalRef, portalRevision, openSheet, dismissSheet, @@ -152,8 +161,7 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode }) )} /> -
- {!chrome.hasCustomFooter ? Fechar : null} + {chrome.footer ?? Fechar} ) : null} diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index 547eb96c..c543d99b 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -798,6 +798,74 @@ test.describe('Municípios — cards no celular (B42)', () => { await expect(page).toHaveURL(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`) await expect(campaignPageChrome(page, municipality.name)).toBeVisible() }) + + /** + * C109: the custom-footer sheets ("Registrar atualização" here, the same + * mechanism as E14's "Registrar movimento") used to portal their footer from + * the cell into the shared Drawer. `createPortal` keeps the source context, + * which has no `Drawer.Root`, so base-ui's `DialogClose` threw and the sheet + * never rendered. The footer must open, submit, and close inside the shared + * Drawer. + */ + test('opens the update quick-edit sheet, registers, and closes via Cancelar (C109)', async ({ + campaign, + page, + }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora C109'), + }) + const password = coordinator.password + const municipality = await fixtures.claimMunicipality() + + await campaign.login(page, coordinator.email!, password) + const listURL = `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}` + await page.goto(listURL) + + const card = page.locator('[data-view="mobile-cards"] article').first() + await expect(card).toBeVisible() + await card.getByRole('button', { name: /^Registrar atualização em/ }).click() + + // The sheet's primary action lives in the custom footer — the two buttons + // the phone thumb expects, rendered inside the shared Drawer. + const sheet = page.getByRole('dialog', { name: 'Registrar atualização' }) + await expect(sheet).toBeVisible() + await expect( + sheet.getByRole('button', { name: 'Registrar atualização', exact: true }), + ).toBeVisible() + await expect(sheet.getByRole('button', { name: 'Cancelar' })).toBeVisible() + await expect(page).toHaveURL(listURL) + + await sheet + .getByRole('textbox', { name: 'Texto' }) + .fill('Sinal registrado pelo sheet mobile do card.') + await sheet.getByLabel('Polaridade').getByRole('radio', { name: 'Boa' }).click() + await Promise.all([ + expectPostResponse(page, '/campanha/municipios'), + sheet.getByRole('button', { name: 'Registrar atualização', exact: true }).click(), + ]) + + // The toast is intentionally transient; the persisted freshness label is + // the stable success contract and must arrive after the server response. + await expect( + card.getByRole('button', { name: `Registrar atualização em ${municipality.name} — hoje` }), + ).toBeVisible() + + // Reopen: the chrome is rebuilt fresh, and the custom footer comes back. + await card.getByRole('button', { name: /^Registrar atualização em/ }).click() + await expect(sheet).toBeVisible() + await sheet.getByRole('button', { name: 'Cancelar' }).click() + await expect(sheet).toBeHidden() + + // E14's level sheet is the same custom-footer mechanism — the other caller + // that shares the crash. It must open and close without one too. + await card.getByRole('button', { name: /^Nível de envolvimento de/ }).click() + const levelSheet = page.getByRole('dialog', { name: 'Registrar nível de envolvimento' }) + await expect(levelSheet).toBeVisible() + await expect(levelSheet.getByRole('button', { name: 'Registrar movimento' })).toBeVisible() + await levelSheet.getByRole('button', { name: 'Cancelar' }).click() + await expect(levelSheet).toBeHidden() + }) }) /** diff --git a/tests/unit/campaignCellEditOverlay.unit.spec.ts b/tests/unit/campaignCellEditOverlay.unit.spec.ts index 631feaf6..243a881d 100644 --- a/tests/unit/campaignCellEditOverlay.unit.spec.ts +++ b/tests/unit/campaignCellEditOverlay.unit.spec.ts @@ -9,6 +9,7 @@ import { MunicipalityListLevelControl } from '@/components/campaign/municipality import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl' import { MunicipalityListUpdateControl } from '@/components/campaign/municipality/MunicipalityListUpdateControl' import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay' +import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost' import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell' import { TooltipProvider } from '@/components/ui/tooltip' import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate' @@ -338,6 +339,148 @@ describe('campaign cell edit overlay', () => { await waitFor(() => expect(formAction).toHaveBeenCalledTimes(1)) }) + /** + * C109. The cases above render each cell WITHOUT the provider — they exercise + * the per-cell Drawer fallback, which never hit the crash. On the real mobile + * list a `CampaignListSheetProvider` wraps the cards, the overlay publishes + * into its single Drawer, and the custom footer used to be portaled from the + * cell: `createPortal` keeps the SOURCE tree's React context, so the + * `DrawerCloseButton` inside the footer resolved `useDialogRootContext()` + * as undefined and the sheet never rendered. These cases pin the shared path: + * the footer must render INSIDE the host's Drawer (context present), close + * via "Cancelar", and come back fresh on a reopen. + */ + describe('shared sheet host path (B42 provider)', () => { + it('opens the signal sheet with its custom footer inside the shared Drawer, and Cancelar closes it', async () => { + render( + createElement( + CampaignListSheetProvider, + null, + // `createElement`'s typing puts a required `children` in props, so + // the rest-argument form this rule prefers does not type-check here. + // eslint-disable-next-line react/no-children-prop + createElement(MunicipalityListUpdateControl, { + municipalityID: 1, + municipalitySlug: 'feira-de-santana', + municipalityName: MUNICIPALITY_NAME, + lastSignalAt: null, + variant: 'sheet', + formAction: async () => ({}), + isStaff: true, + children: createElement('span', null, 'Sem sinal'), + }), + ), + ) + + fireEvent.click( + screen.getByRole('button', { + name: `Registrar atualização em ${MUNICIPALITY_NAME} — Sem sinal`, + }), + ) + + const dialog = await screen.findByRole('dialog') + const footer = dialog.querySelector('[data-slot="drawer-footer"]') + // The custom footer replaces the default "Fechar" — the two buttons the + // phone thumb expects, rendered in the host's Drawer context. + expect(footer?.textContent).toContain('Registrar atualização') + expect(footer?.textContent).toContain('Cancelar') + expect(footer?.textContent).not.toContain('Fechar') + + fireEvent.click(within(footer as HTMLElement).getByRole('button', { name: 'Cancelar' })) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + }) + + /** + * F5 across the provider path: the form body is portaled into the shared + * Drawer and the submit button lives in the host-rendered footer — the + * standard `form` attribute has to reach across that boundary for the + * association to survive. The fallback-path twin above pins the same + * contract in the per-cell Drawer. + */ + it('submits the signal form from the custom footer across the shared Drawer', async () => { + const formAction = vi.fn(async () => ({})) + + render( + createElement( + CampaignListSheetProvider, + null, + // eslint-disable-next-line react/no-children-prop + createElement(MunicipalityListUpdateControl, { + municipalityID: 1, + municipalitySlug: 'feira-de-santana', + municipalityName: MUNICIPALITY_NAME, + lastSignalAt: null, + variant: 'sheet', + formAction, + isStaff: true, + children: createElement('span', null, 'Sem sinal'), + }), + ), + ) + + fireEvent.click( + screen.getByRole('button', { + name: `Registrar atualização em ${MUNICIPALITY_NAME} — Sem sinal`, + }), + ) + + const dialog = await screen.findByRole('dialog') + fireEvent.click( + within(screen.getByLabelText('Polaridade')).getByRole('radio', { name: 'Boa' }), + ) + fireEvent.change(screen.getByLabelText('Texto'), { + target: { value: 'Atualização registrada no campo.' }, + }) + + const submit = dialog.querySelector('[data-slot="drawer-footer"] button') + expect(submit?.textContent).toContain('Registrar atualização') + expect(submit?.getAttribute('form')).toBe( + dialog.querySelector('form')?.getAttribute('id') ?? null, + ) + + fireEvent.click(submit as HTMLButtonElement) + + await waitFor(() => expect(formAction).toHaveBeenCalledTimes(1)) + }) + + it('opens the engagement-level sheet, closes, and reopens with a fresh custom footer', async () => { + render( + createElement( + CampaignListSheetProvider, + null, + createElement(MunicipalityListLevelControl, { + municipalityID: 1, + municipalityName: MUNICIPALITY_NAME, + level: 'n3', + levelNote: 'Rede montada e giro agendado', + levelChangedAt: '2026-07-01T12:00:00.000Z', + variant: 'sheet', + }), + ), + ) + + const trigger = screen.getByRole('button', { + name: `Nível de envolvimento de ${MUNICIPALITY_NAME}: N3 · Rede + agenda`, + }) + + fireEvent.click(trigger) + let dialog = await screen.findByRole('dialog') + let footer = dialog.querySelector('[data-slot="drawer-footer"]') + expect(footer?.textContent).toContain('Registrar movimento') + expect(footer?.textContent).toContain('Cancelar') + expect(footer?.textContent).not.toContain('Fechar') + + fireEvent.click(within(footer as HTMLElement).getByRole('button', { name: 'Cancelar' })) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + + // Reopening mounts a fresh chrome — the custom footer must come back. + fireEvent.click(trigger) + dialog = await screen.findByRole('dialog') + footer = dialog.querySelector('[data-slot="drawer-footer"]') + expect(footer?.textContent).toContain('Registrar movimento') + }) + }) + /** * B34's cell is the one caller that is a sheet at EVERY viewport: it picks its * surface by `pointer:`, not by width, so it has no `variant` to sweep and