mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
fix(campanha): quick-edit sheet mobile com footer custom quebrava ao abrir (C109)
O footer custom ('Registrar atualização' B42, 'Registrar movimento' E14) era
portalado da célula para o DrawerFooter do shared-sheet: createPortal preserva
o contexto React da origem (sem Drawer.Root), então o DrawerCloseButton
(base-ui DialogClose) crashava em useDialogRootContext() e o sheet nunca
renderizava.
O footer agora viaja no chrome do CampaignListSheetHost (mesmo mecanismo de
title/description) e é renderizado inline no DrawerFooter — contexto correto
por construção. O elemento continua criado na célula (estado do form e
associação form attribute preservados). footerPortalRef/attachFooterPortal/
hasCustomFooter removidos; fallback sem provider e popover intactos.
Cobertura: unit do shared path (abre/fecha/reabre + submit via form attribute)
e e2e mobile no describe B42 (abre, registra, fecha — incluindo o sheet do
nível E14).
This commit is contained in:
parent
c4591eedaa
commit
5ad2f7c722
5 changed files with 297 additions and 18 deletions
62
docs/plans/quick-edit-sheet-mobile-quebrado-impl.md
Normal file
62
docs/plans/quick-edit-sheet-mobile-quebrado-impl.md
Normal file
|
|
@ -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<br/>(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 ?? <DrawerCloseButton>Fechar</DrawerCloseButton>}` — 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
|
||||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement | null>
|
||||
footerPortalRef: RefObject<HTMLDivElement | null>
|
||||
/** 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<HTMLHeadingElement | null>(null)
|
||||
const bodyPortalRef = useRef<HTMLDivElement | null>(null)
|
||||
const footerPortalRef = useRef<HTMLDivElement | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [chrome, setChrome] = useState<CampaignListSheetChrome | null>(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 })
|
|||
)}
|
||||
/>
|
||||
<DrawerFooter>
|
||||
<div ref={attachFooterPortal} className="flex w-full flex-col gap-2" />
|
||||
{!chrome.hasCustomFooter ? <DrawerCloseButton>Fechar</DrawerCloseButton> : null}
|
||||
{chrome.footer ?? <DrawerCloseButton>Fechar</DrawerCloseButton>}
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<HTMLButtonElement>('[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
|
||||
|
|
|
|||
Loading…
Reference in a new issue