- MunicipalityUpdateFields ganha layout 'labeled'|'list'; polaridade vira ToggleGroup Ruim|Neutra|Boa nas duas resoluções (mesmo dado, hidden input) - layout 'list': labels sr-only, textarea sem borda, divisórias divide-y full-bleed, Urgente/Sinalizar adversário como linha tocável com check à direita - StrictCombobox: prop placeholder (backward-compatible) - Sheet do feed: max-h-dvh (teto no topo, rola por dentro), título sr-only, form lista com placeholders descritivos; desktop preservado - Quick-edit B42 herda o visual no sheet mobile via layout - unit/e2e atualizados para o toggle (radio)
373 lines
15 KiB
TypeScript
373 lines
15 KiB
TypeScript
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||
import { createElement, type ReactElement } from 'react'
|
||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||
|
||
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
|
||
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
|
||
import { MunicipalityListLeadershipsControl } from '@/components/campaign/municipality/MunicipalityListLeadershipsControl'
|
||
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
|
||
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
|
||
import { MunicipalityListUpdateControl } from '@/components/campaign/municipality/MunicipalityListUpdateControl'
|
||
import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
|
||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
|
||
|
||
/**
|
||
* B42: the quick-edit cells of `/campanha/municipios` share one container
|
||
* (`CampaignCellEditOverlay`) that is a Popover in the desktop table and a
|
||
* bottom Drawer in the mobile card. These cases pin the part a call site can
|
||
* silently get wrong — which container it renders and whether the touch surface
|
||
* says which município is being edited — for all of them at once. Three
|
||
* auto-save; the level cell (E14) submits explicitly and is here for the
|
||
* container contract all four share.
|
||
*/
|
||
const MUNICIPALITY_NAME = 'Feira de Santana'
|
||
|
||
type OverlayCase = {
|
||
name: string
|
||
/**
|
||
* The accessible name of the trigger. It replaces the pill / avatar stack /
|
||
* number it wraps, so it has to carry that value — each case spells the value
|
||
* out, which is the part a refactor drops without noticing.
|
||
*/
|
||
triggerLabel: string
|
||
/** Text the Drawer header must carry beyond the município name. */
|
||
drawerTitle: string
|
||
/**
|
||
* Label of the sheet's primary footer action, for the cells whose commit is
|
||
* an explicit submit (B32+ F5). A footer REPLACES the default "Fechar", so
|
||
* the two assertions are exclusive.
|
||
*/
|
||
footerLabel?: string
|
||
element: (variant: CampaignCellEditOverlayVariant) => ReactElement
|
||
}
|
||
|
||
const overlayCases: OverlayCase[] = [
|
||
{
|
||
name: 'tendência',
|
||
triggerLabel: `Editar tendência política em ${MUNICIPALITY_NAME} — Favorável`,
|
||
drawerTitle: 'Editar tendência',
|
||
element: (variant) =>
|
||
createElement(MunicipalityListTrendControl, {
|
||
municipalityID: 1,
|
||
municipalityName: MUNICIPALITY_NAME,
|
||
status: 'favoravel',
|
||
trendNote: 'Vereador migrou para a base',
|
||
variant,
|
||
}),
|
||
},
|
||
{
|
||
name: 'assessores',
|
||
triggerLabel: `Editar assessores em ${MUNICIPALITY_NAME} — Ana Bastos`,
|
||
drawerTitle: 'Atribuir assessores',
|
||
element: (variant) =>
|
||
createElement(MunicipalityListAdvisorsControl, {
|
||
municipalityID: 1,
|
||
municipalityName: MUNICIPALITY_NAME,
|
||
currentAdvisorIDs: [7],
|
||
isPriority: false,
|
||
advisorNamesById: new Map([[7, { id: 7, name: 'Ana Bastos', phone: null }]]),
|
||
options: [{ id: 7, name: 'Ana Bastos', isCurrent: false }],
|
||
variant,
|
||
}),
|
||
},
|
||
{
|
||
name: 'lideranças',
|
||
triggerLabel: `Editar lideranças em ${MUNICIPALITY_NAME} — Maria de Jesus`,
|
||
drawerTitle: 'Gerenciar lideranças',
|
||
element: (variant) =>
|
||
createElement(MunicipalityListLeadershipsControl, {
|
||
municipalityID: 1,
|
||
municipalityName: MUNICIPALITY_NAME,
|
||
currentLeadershipIDs: [11],
|
||
leadershipNamesById: new Map([[11, { id: 11, name: 'Maria de Jesus' }]]),
|
||
options: [{ id: 11, name: 'Maria de Jesus' }],
|
||
variant,
|
||
}),
|
||
},
|
||
{
|
||
name: 'votos estimados',
|
||
triggerLabel: `Editar votos estimados em ${MUNICIPALITY_NAME} — Média: 1.200`,
|
||
drawerTitle: 'Editar votos estimados',
|
||
element: (variant) =>
|
||
createElement(MunicipalityListExpectedVotesControl, {
|
||
municipalityID: 1,
|
||
municipalityName: MUNICIPALITY_NAME,
|
||
expectedVotes: toVoteEstimateScenarioViewModel({ central: 1200 }),
|
||
pledgeCoverage: null,
|
||
variant,
|
||
}),
|
||
},
|
||
{
|
||
name: 'nível de envolvimento',
|
||
triggerLabel: `Nível de envolvimento de ${MUNICIPALITY_NAME}: N3 · Rede + agenda`,
|
||
drawerTitle: 'Registrar nível de envolvimento',
|
||
footerLabel: 'Registrar movimento',
|
||
element: (variant) =>
|
||
createElement(MunicipalityListLevelControl, {
|
||
municipalityID: 1,
|
||
municipalityName: MUNICIPALITY_NAME,
|
||
level: 'n3',
|
||
levelNote: 'Rede montada e giro agendado',
|
||
levelChangedAt: '2026-07-01T12:00:00.000Z',
|
||
variant,
|
||
}),
|
||
},
|
||
{
|
||
name: 'sinal',
|
||
triggerLabel: `Registrar atualização em ${MUNICIPALITY_NAME} — Sem sinal`,
|
||
drawerTitle: 'Registrar atualização',
|
||
footerLabel: 'Registrar atualização',
|
||
element: (variant) =>
|
||
// `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,
|
||
formAction: async () => ({}),
|
||
isStaff: true,
|
||
children: createElement('span', null, 'Sem sinal'),
|
||
}),
|
||
},
|
||
]
|
||
|
||
beforeAll(() => {
|
||
// The advisors control renders `cmdk`, which measures its list and keeps the
|
||
// selected item in view. jsdom implements neither. Same `ResizeObserver` stub
|
||
// as `campaignCompositionCleanup.unit.spec.ts`.
|
||
vi.stubGlobal(
|
||
'ResizeObserver',
|
||
class {
|
||
observe() {}
|
||
unobserve() {}
|
||
disconnect() {}
|
||
},
|
||
)
|
||
Element.prototype.scrollIntoView = () => {}
|
||
})
|
||
|
||
afterEach(cleanup)
|
||
|
||
afterAll(() => {
|
||
vi.unstubAllGlobals()
|
||
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
|
||
})
|
||
|
||
// The Popover variant wraps its trigger in `CampaignHoverTooltip` (Radix),
|
||
// which refuses to render outside a provider; production mounts it in the
|
||
// `(app)` layout.
|
||
const renderWithTooltip = (ui: ReactElement) => render(createElement(TooltipProvider, null, ui))
|
||
|
||
describe('campaign cell edit overlay', () => {
|
||
it.each(overlayCases)(
|
||
'opens $name in a Drawer that names the município on touch',
|
||
async ({ triggerLabel, drawerTitle, footerLabel, element }) => {
|
||
renderWithTooltip(element('sheet'))
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: triggerLabel }))
|
||
|
||
const dialog = await screen.findByRole('dialog')
|
||
const title = dialog.querySelector('[data-slot="drawer-title"]')
|
||
expect(title?.textContent).toBe(drawerTitle)
|
||
expect(dialog.querySelector('[data-slot="drawer-description"]')?.textContent).toBe(
|
||
MUNICIPALITY_NAME,
|
||
)
|
||
// Focus lands on the title, never on a field: on touch the alternative is
|
||
// the virtual keyboard covering the sheet before it can be read. Awaited
|
||
// because the advisors sheet mounts `cmdk`, which delays it by a tick.
|
||
await waitFor(() => expect(document.activeElement).toBe(title))
|
||
const footer = dialog.querySelector('[data-slot="drawer-footer"]')
|
||
if (footerLabel) {
|
||
// A footer replaces the default dismissal: the commit is the primary
|
||
// action and "Cancelar" is what stands in for "Fechar".
|
||
expect(footer?.textContent).toContain(footerLabel)
|
||
expect(footer?.textContent).not.toContain('Fechar')
|
||
} else {
|
||
// Swiping down is the gesture, but it is not an affordance anyone sees.
|
||
expect(footer?.textContent).toContain('Fechar')
|
||
}
|
||
expect(document.querySelector('[data-slot="popover-content"]')).toBeNull()
|
||
},
|
||
)
|
||
|
||
it.each(overlayCases)(
|
||
'keeps $name in a Popover on the desktop table',
|
||
async ({ triggerLabel, element }) => {
|
||
renderWithTooltip(element('popover'))
|
||
|
||
const trigger = screen.getByRole('button', { name: triggerLabel })
|
||
expect(trigger.getAttribute('aria-haspopup')).toBe('dialog')
|
||
|
||
fireEvent.click(trigger)
|
||
|
||
await screen.findByRole('dialog')
|
||
expect(document.querySelector('[data-slot="popover-content"]')).not.toBeNull()
|
||
expect(document.querySelector('[data-slot="drawer-popup"]')).toBeNull()
|
||
// Header and subject line are Drawer chrome. A Popover is anchored to the
|
||
// trigger it came from, and that trigger's accessible name already carries
|
||
// both the município and the current value.
|
||
expect(document.querySelector('[data-slot="drawer-title"]')).toBeNull()
|
||
expect(document.querySelector('[data-slot="drawer-description"]')).toBeNull()
|
||
},
|
||
)
|
||
|
||
/**
|
||
* B32+ F4. The region used to live inside the overlay, and closing is exactly
|
||
* what commits a draft — so the announcement that mattered most was the one
|
||
* that unmounted before it could be read. It now sits beside the trigger,
|
||
* which outlives the dialog.
|
||
*/
|
||
/**
|
||
* The other half of the same policy: a region mounted on every row would
|
||
* register ~250 polite live regions on one município page (5 controls × the
|
||
* card and table trees), so it appears with the first open — early enough for
|
||
* "Salvando…", which cannot happen before one.
|
||
*/
|
||
it('does not mount the live region until the overlay has been opened once', () => {
|
||
renderWithTooltip(
|
||
createElement(MunicipalityListTrendControl, {
|
||
municipalityID: 1,
|
||
municipalityName: MUNICIPALITY_NAME,
|
||
status: 'favoravel',
|
||
trendNote: null,
|
||
variant: 'popover',
|
||
}),
|
||
)
|
||
|
||
expect(document.querySelector('[aria-live="polite"]')).toBeNull()
|
||
|
||
fireEvent.click(
|
||
screen.getByRole('button', {
|
||
name: `Editar tendência política em ${MUNICIPALITY_NAME} — Favorável`,
|
||
}),
|
||
)
|
||
|
||
expect(document.querySelector('[aria-live="polite"]')).not.toBeNull()
|
||
})
|
||
|
||
it('keeps the live region mounted after the overlay closes', async () => {
|
||
const fetchMock = vi.fn(
|
||
() =>
|
||
new Promise(() => {
|
||
// Never settles: "Salvando…" is the state under test.
|
||
}),
|
||
)
|
||
vi.stubGlobal('fetch', fetchMock)
|
||
|
||
renderWithTooltip(
|
||
createElement(MunicipalityListTrendControl, {
|
||
municipalityID: 1,
|
||
municipalityName: MUNICIPALITY_NAME,
|
||
status: 'favoravel',
|
||
trendNote: null,
|
||
variant: 'popover',
|
||
}),
|
||
)
|
||
|
||
const trigger = screen.getByRole('button', {
|
||
name: `Editar tendência política em ${MUNICIPALITY_NAME} — Favorável`,
|
||
})
|
||
fireEvent.click(trigger)
|
||
|
||
const select = await screen.findByLabelText('Tendência')
|
||
fireEvent.change(select, { target: { value: 'desfavoravel' } })
|
||
// Dismissing flushes the debounced save, and the dialog goes with it.
|
||
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' })
|
||
|
||
await waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull())
|
||
await waitFor(() => {
|
||
const region = document.querySelector('[aria-live="polite"]')
|
||
expect(region?.textContent).toBe('Salvando tendência.')
|
||
})
|
||
})
|
||
|
||
/**
|
||
* The sheet's submit sits in the footer, OUTSIDE the `<form>` it belongs to —
|
||
* associated by the standard `form` attribute. This case is what says that
|
||
* association still reaches React's action (B32+ F5); without it the button
|
||
* would look right and do nothing.
|
||
*/
|
||
it('submits the signal form from a button rendered in the sheet footer', async () => {
|
||
const formAction = vi.fn(async () => ({}))
|
||
|
||
renderWithTooltip(
|
||
// `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,
|
||
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')
|
||
// The polarity toggle (C107) is `single` with `neutra` as default; the
|
||
// sheet renders the list layout, so the label is visually hidden but the
|
||
// group keeps its accessible name. Switching the value keeps the form
|
||
// valid and exercises the control before submit.
|
||
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))
|
||
})
|
||
|
||
/**
|
||
* 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
|
||
* cannot join the cases above. It is here for the half it does share — the
|
||
* sheet chrome and, above all, `initialFocus`: its own Drawer used to hand
|
||
* focus to the first chip, a link that navigates away from the row being
|
||
* edited.
|
||
*/
|
||
it('opens the município portfolio in a Drawer focused on its title', async () => {
|
||
renderWithTooltip(
|
||
createElement(MunicipalityPortfolioCell, {
|
||
ownerId: 1,
|
||
ownerName: 'Maria Souza',
|
||
municipalityIds: [11],
|
||
municipalityIndex: [{ id: 11, slug: 'feira-de-santana' }],
|
||
commitAction: async () => ({}),
|
||
drawerTitle: 'Editar municípios',
|
||
updateErrorMessage: 'Não foi possível atualizar os municípios.',
|
||
}),
|
||
)
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Editar municípios de Maria Souza' }))
|
||
|
||
const dialog = await screen.findByRole('dialog')
|
||
const title = dialog.querySelector('[data-slot="drawer-title"]')
|
||
expect(title?.textContent).toBe('Editar municípios')
|
||
expect(dialog.querySelector('[data-slot="drawer-description"]')?.textContent).toBe(
|
||
'Maria Souza',
|
||
)
|
||
await waitFor(() => expect(document.activeElement).toBe(title))
|
||
expect(dialog.textContent).toContain('Fechar')
|
||
})
|
||
})
|