Merge pull request #153 from fsolla/cursor/pool-i148-3c37da6e-2281

B109 — Bottom drawer: dock polish, fullscreen search, exclude current entity
This commit is contained in:
Francisco Solla 2026-08-01 18:03:27 -03:00 committed by GitHub
commit ab910b499b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 354 additions and 39 deletions

View file

@ -28,7 +28,7 @@ export type CampaignHomeActionButtonProps = {
}
export const actionControlClassName =
'group box-content flex min-h-11 w-[4.75rem] shrink-0 snap-start flex-col items-center gap-2 rounded-md px-2 text-center outline-none focus-visible:ring-2 focus-visible:ring-ring active:opacity-90 disabled:pointer-events-none disabled:opacity-50'
'group box-content flex min-h-11 w-[5.5rem] shrink-0 snap-start flex-col items-center gap-2 rounded-md text-center outline-none focus-visible:ring-2 focus-visible:ring-ring active:opacity-90 disabled:pointer-events-none disabled:opacity-50'
const circleClassName =
'flex size-14 items-center justify-center rounded-full bg-muted text-foreground transition-[transform,colors] duration-150 ease-out motion-reduce:transition-none pointer-fine:group-hover:scale-[1.05] pointer-coarse:group-data-[pressing=true]:scale-[1.05] motion-reduce:scale-100 group-hover:bg-muted/80 group-focus-visible:bg-muted/80'

View file

@ -0,0 +1,20 @@
'use client'
import { createContext, useContext, type ReactNode } from 'react'
import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext'
const HomeSearchExcludeContext = createContext<CampaignQuickActionContext | null>(null)
export const HomeSearchExcludeProvider = ({
value,
children,
}: {
value: CampaignQuickActionContext
children: ReactNode
}) => (
<HomeSearchExcludeContext.Provider value={value}>{children}</HomeSearchExcludeContext.Provider>
)
export const useHomeSearchExcludeContext = (): CampaignQuickActionContext | null =>
useContext(HomeSearchExcludeContext)

View file

@ -11,9 +11,11 @@ import {
} from 'react'
import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext'
import { useHomeSearchExcludeContext } from '@/components/campaign/dashboard/HomeSearchExcludeContext'
import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits'
import { HOME_SEARCH_GENERIC_ERROR_MESSAGE } from '@/lib/campaignHomeSearchMessages'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { filterHomeSearchResponseForContext } from '@/lib/homeSearchExcludeCurrentEntity'
const HOME_SEARCH_ROUTE = '/campanha/home-search'
@ -70,6 +72,7 @@ type HomeSearchErrorResponse = { status: 'error'; message: string }
export const useHomeSearchResultsState = (): HomeSearchResultsContextValue => {
const { query, isDebouncing, uiFocused } = useHomeSearch()
const excludeContext = useHomeSearchExcludeContext()
const initialSuggest = useContext(InitialHomeSearchSuggestContext)
const [results, setResults] = useState<HomeSearchResultsState>({ status: 'idle' })
const requestSeq = useRef(0)
@ -136,12 +139,20 @@ export const useHomeSearchResultsState = (): HomeSearchResultsContextValue => {
const isFetching = isDebouncing || results.status === 'loading'
const filteredResults = useMemo((): HomeSearchResultsState => {
if (results.status !== 'success' || !excludeContext) return results
return {
status: 'success',
data: filterHomeSearchResponseForContext(results.data, excludeContext),
}
}, [excludeContext, results])
return useMemo(
() => ({
results,
results: filteredResults,
isFetching,
resultKind: results.status === 'success' ? results.data.resultKind : 'idle',
resultKind: filteredResults.status === 'success' ? filteredResults.data.resultKind : 'idle',
}),
[results, isFetching],
[filteredResults, isFetching],
)
}

View file

@ -1,36 +1,59 @@
'use client'
import { usePathname } from 'next/navigation'
import { useCallback, useEffect } from 'react'
import { CampaignGlobalSearchBody } from '@/components/campaign/dashboard/CampaignGlobalSearchMount'
import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip'
import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext'
import { HomeSearchExcludeProvider } from '@/components/campaign/dashboard/HomeSearchExcludeContext'
import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext'
import { useCampaignQuickActionsSnap } from '@/components/campaign/shell/CampaignQuickActionsSnapContext'
import { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/Drawer'
import {
QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX,
QUICK_ACTIONS_SNAP_COLLAPSED,
QUICK_ACTIONS_SNAP_DOCK,
QUICK_ACTIONS_SNAP_FULL,
QUICK_ACTIONS_SNAP_POINTS,
quickActionsSnapIsDock,
quickActionsSnapIsFull,
type QuickActionsSnapPoint,
} from '@/lib/campaignQuickActionSnap'
import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes'
import { resolveHomeSearchExcludeContext } from '@/lib/homeSearchExcludeCurrentEntity'
import { cn } from '@/lib/utils'
const SNAP_POINTS = [...QUICK_ACTIONS_SNAP_POINTS]
const restoreSnapAfterSearch = (): QuickActionsSnapPoint => {
const scrollport = document.querySelector('[data-slot="campaign-content-scroll"]')
const scrollTop = scrollport instanceof HTMLElement ? scrollport.scrollTop : 0
return scrollTop > QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX
? QUICK_ACTIONS_SNAP_COLLAPSED
: QUICK_ACTIONS_SNAP_DOCK
}
export const CampaignQuickActionsDrawer = ({
actions,
}: {
actions: readonly CampaignQuickAction[]
}) => {
const { snapPoint, setSnapPoint, isDock } = useCampaignQuickActionsSnap()
const pathname = usePathname()
const { context } = useCampaignQuickActionContext()
const excludeContext = resolveHomeSearchExcludeContext(pathname, context)
const { snapPoint, setSnapPoint, isDock, isFull } = useCampaignQuickActionsSnap()
const { uiFocused } = useHomeSearch()
const showActions = actions.length > 0
const showActions = actions.length > 0 && isDock && !isFull
const showSearchResults = isDock || isFull
const handleSnapPointChange = useCallback(
(next: QuickActionsSnapPoint | string | number | null) => {
if (next === QUICK_ACTIONS_SNAP_COLLAPSED || next === QUICK_ACTIONS_SNAP_DOCK) {
if (
next === QUICK_ACTIONS_SNAP_COLLAPSED ||
next === QUICK_ACTIONS_SNAP_DOCK ||
next === QUICK_ACTIONS_SNAP_FULL
) {
setSnapPoint(next)
return
}
@ -40,9 +63,11 @@ export const CampaignQuickActionsDrawer = ({
)
const toggleSnap = useCallback(() => {
setSnapPoint((current) =>
quickActionsSnapIsDock(current) ? QUICK_ACTIONS_SNAP_COLLAPSED : QUICK_ACTIONS_SNAP_DOCK,
)
setSnapPoint((current) => {
if (quickActionsSnapIsFull(current)) return restoreSnapAfterSearch()
if (quickActionsSnapIsDock(current)) return QUICK_ACTIONS_SNAP_COLLAPSED
return QUICK_ACTIONS_SNAP_DOCK
})
}, [setSnapPoint])
const handleOpenChange = useCallback(
@ -54,49 +79,62 @@ export const CampaignQuickActionsDrawer = ({
[setSnapPoint],
)
// Active search focus keeps the drawer docked (B105) — same gate as scroll.
useEffect(() => {
if (uiFocused && !isDock) {
setSnapPoint(QUICK_ACTIONS_SNAP_DOCK)
if (!uiFocused) return
if (!quickActionsSnapIsFull(snapPoint)) {
setSnapPoint(QUICK_ACTIONS_SNAP_FULL)
}
}, [isDock, setSnapPoint, uiFocused])
}, [snapPoint, setSnapPoint, uiFocused])
useEffect(() => {
if (uiFocused) return
setSnapPoint(restoreSnapAfterSearch())
}, [setSnapPoint, uiFocused])
return (
<Drawer
open
modal={false}
swipeDirection="down"
snapPoints={SNAP_POINTS}
snapPoint={snapPoint}
snapPoints={uiFocused ? [QUICK_ACTIONS_SNAP_FULL] : SNAP_POINTS}
snapPoint={uiFocused ? QUICK_ACTIONS_SNAP_FULL : snapPoint}
onSnapPointChange={handleSnapPointChange}
onOpenChange={handleOpenChange}
disablePointerDismissal
>
<DrawerContent
id="CampaignQuickActionsDrawer"
className="border-t border-border bg-background text-foreground shadow-[0_-4px_24px_-8px_rgb(0_0_0/0.12)] print:hidden"
className={cn(
'border-t border-border bg-background text-foreground shadow-[0_-4px_24px_-8px_rgb(0_0_0/0.12)] print:hidden',
isFull && 'z-[60] h-dvh max-h-dvh [--drawer-height:100dvh]',
)}
>
<DrawerTitle className="sr-only">Ações rápidas</DrawerTitle>
<button
type="button"
onClick={toggleSnap}
aria-expanded={isDock}
aria-expanded={isDock || isFull}
aria-controls="quickActionContext"
className="flex w-full shrink-0 cursor-grab flex-col items-center border-0 bg-transparent px-4 pt-2 pb-1 active:cursor-grabbing"
aria-label={isDock ? 'Ocultar ações rápidas' : 'Mostrar ações rápidas'}
className="flex w-full shrink-0 cursor-grab flex-col items-center border-0 bg-transparent px-4 pt-2 pb-0.5 active:cursor-grabbing"
aria-label={isDock || isFull ? 'Ocultar ações rápidas' : 'Mostrar ações rápidas'}
>
<span aria-hidden className="mb-1 block h-1 w-12 rounded-full bg-muted" />
</button>
<div
id="quickActionContext"
className={cn(
'flex min-h-0 flex-col gap-3 overflow-y-auto px-4 pb-[max(0.5rem,env(safe-area-inset-bottom,0px))]',
isDock ? 'flex-1' : 'shrink-0',
'flex min-h-0 flex-col overflow-y-auto px-4',
isFull
? 'flex-1 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom,0px))]'
: cn(
'gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom,0px))]',
isDock ? 'flex-1' : 'shrink-0',
),
)}
data-snap={isDock ? 'dock' : 'collapsed'}
data-snap={isFull ? 'full' : isDock ? 'dock' : 'collapsed'}
>
{showActions ? (
<div hidden={!isDock} className="-mx-4 w-[calc(100%+2rem)]">
<div className="-mx-4 w-[calc(100%+2rem)]">
<CampaignHomeActionStrip
actions={actions.map((action) => ({
id: action.id,
@ -109,7 +147,12 @@ export const CampaignQuickActionsDrawer = ({
/>
</div>
) : null}
<CampaignGlobalSearchBody placeholder={isDock ? undefined : ''} showResults={isDock} />
<HomeSearchExcludeProvider value={excludeContext}>
<CampaignGlobalSearchBody
placeholder={isDock || isFull ? undefined : ''}
showResults={showSearchResults}
/>
</HomeSearchExcludeProvider>
</div>
</DrawerContent>
</Drawer>

View file

@ -21,9 +21,13 @@ import { cn } from '@/lib/utils'
const CampaignContentScrollWithPeek = ({ children }: { children: ReactNode }) => {
const scrollRef = useRef<HTMLDivElement>(null)
const lastScrollTopRef = useRef(0)
const { isDock, setSnapPoint } = useCampaignQuickActionsSnap()
const { isDock, isFull, setSnapPoint } = useCampaignQuickActionsSnap()
const { uiFocused } = useHomeSearch()
const peekHeight = isDock ? QUICK_ACTIONS_SNAP_DOCK : QUICK_ACTIONS_SNAP_COLLAPSED
const peekHeight = isFull
? '0px'
: isDock
? QUICK_ACTIONS_SNAP_DOCK
: QUICK_ACTIONS_SNAP_COLLAPSED
const syncSnapFromScroll = useCallback(() => {
const scrollport = scrollRef.current

View file

@ -15,6 +15,7 @@ import {
import {
QUICK_ACTIONS_SNAP_DOCK,
quickActionsSnapIsDock,
quickActionsSnapIsFull,
type QuickActionsSnapPoint,
} from '@/lib/campaignQuickActionSnap'
@ -22,6 +23,7 @@ type CampaignQuickActionsSnapContextValue = {
snapPoint: QuickActionsSnapPoint | null
setSnapPoint: Dispatch<SetStateAction<QuickActionsSnapPoint | null>>
isDock: boolean
isFull: boolean
}
const CampaignQuickActionsSnapContext = createContext<CampaignQuickActionsSnapContextValue | null>(
@ -41,6 +43,7 @@ export const CampaignQuickActionsSnapProvider = ({ children }: { children: React
snapPoint,
setSnapPoint,
isDock: quickActionsSnapIsDock(snapPoint),
isFull: quickActionsSnapIsFull(snapPoint),
}),
[snapPoint],
)

View file

@ -9,6 +9,7 @@ export type CampaignQuickActionContext = {
organizationSlug?: string
activitySlug?: string
demandSlug?: string
advisorId?: number
}
export const emptyCampaignQuickActionContext = (): CampaignQuickActionContext => ({})

View file

@ -4,6 +4,9 @@ export const QUICK_ACTIONS_SNAP_COLLAPSED = '6.5rem' as const
/** Dock — handle + action strip (2-line labels) + global search. */
export const QUICK_ACTIONS_SNAP_DOCK = '15rem' as const
/** Full viewport — search focus/active; covers mobile top bar (B109). */
export const QUICK_ACTIONS_SNAP_FULL = 1 as const
/**
* Scroll delta past this threshold on `campaign-content-scroll` collapses (↓)
* or re-docks (↑) the drawer.
@ -13,12 +16,16 @@ export const QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX = 24
export const QUICK_ACTIONS_SNAP_POINTS = [
QUICK_ACTIONS_SNAP_COLLAPSED,
QUICK_ACTIONS_SNAP_DOCK,
QUICK_ACTIONS_SNAP_FULL,
] as const
export type QuickActionsSnapPoint = (typeof QUICK_ACTIONS_SNAP_POINTS)[number]
export type QuickActionsScrollDirection = 'up' | 'down' | 'none'
export const quickActionsSnapIsFull = (snap: QuickActionsSnapPoint | null): boolean =>
snap === QUICK_ACTIONS_SNAP_FULL
export const quickActionsSnapIsDock = (snap: QuickActionsSnapPoint | null): boolean =>
snap === QUICK_ACTIONS_SNAP_DOCK

View file

@ -0,0 +1,83 @@
import { parseAdvisorDetailId } from '@/lib/campaignAdvisorQuickActions'
import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits'
import { parseMunicipalityDetailSlug } from '@/lib/campaignMunicipalityQuickActions'
import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext'
import { isDemandDetailPath } from '@/lib/campaignQuickActionDemands'
import {
parseActivityQuickActionSurface,
parseOrganizationQuickActionSurface,
} from '@/lib/campaignQuickActionPaths'
const leadershipDetailIdFromPath = (pathname: string): number | undefined => {
const match = pathname.match(/^\/campanha\/liderancas\/(\d+)(?:\/|$)/)
if (!match?.[1]) return undefined
const id = Number(match[1])
return Number.isInteger(id) && id > 0 ? id : undefined
}
const demandSlugFromPath = (pathname: string): string | undefined => {
if (!isDemandDetailPath(pathname)) return undefined
const rest = pathname.slice('/campanha/demandas/'.length)
return rest || undefined
}
/** Merged pathname + page context for client-side hit exclusion (B109). */
export const resolveHomeSearchExcludeContext = (
pathname: string,
context: CampaignQuickActionContext,
): CampaignQuickActionContext => {
const organizationSurface = parseOrganizationQuickActionSurface(pathname)
const activitySurface = parseActivityQuickActionSurface(pathname)
return {
municipalitySlug: context.municipalitySlug ?? parseMunicipalityDetailSlug(pathname),
municipalityId: context.municipalityId,
leadershipId: context.leadershipId ?? leadershipDetailIdFromPath(pathname),
organizationSlug:
context.organizationSlug ??
(organizationSurface?.kind === 'detail' ? organizationSurface.organizationSlug : undefined),
activitySlug:
context.activitySlug ??
(activitySurface?.kind === 'detail' ? activitySurface.activitySlug : undefined),
demandSlug: context.demandSlug ?? demandSlugFromPath(pathname),
advisorId: context.advisorId ?? parseAdvisorDetailId(pathname),
}
}
const hasExcludeTarget = (context: CampaignQuickActionContext): boolean =>
Boolean(
context.municipalitySlug ||
context.leadershipId !== undefined ||
context.organizationSlug ||
context.activitySlug ||
context.demandSlug ||
context.advisorId !== undefined,
)
export const filterHomeSearchResponseForContext = (
data: HomeSearchSuccessResponse,
context: CampaignQuickActionContext,
): HomeSearchSuccessResponse => {
if (!hasExcludeTarget(context)) return data
return {
...data,
municipalities: context.municipalitySlug
? data.municipalities.filter((hit) => hit.slug !== context.municipalitySlug)
: data.municipalities,
leaderships:
context.leadershipId !== undefined
? data.leaderships.filter((hit) => hit.id !== context.leadershipId)
: data.leaderships,
advisors:
context.advisorId !== undefined
? data.advisors.filter((hit) => hit.id !== context.advisorId)
: data.advisors,
activities: context.activitySlug
? data.activities.filter((hit) => hit.slug !== context.activitySlug)
: data.activities,
demands: context.demandSlug
? data.demands.filter((hit) => hit.slug !== context.demandSlug)
: data.demands,
}
}

View file

@ -458,3 +458,44 @@ test.describe('Municípios — bottom drawer mobile (B105)', () => {
await expect(search).toHaveAttribute('placeholder', 'Município, liderança, atividade…')
})
})
/**
* B109: dock labels readable, search focus goes fullscreen (strip hidden), current
* entity excluded from suggest/results.
*/
test.describe('Municípios — bottom drawer polish (B109)', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('dock labels readable, search fullscreen hides strip, excludes current município', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenador B109'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`)
await expect(page.getByRole('heading', { name: municipality.name })).toBeVisible()
const changeTrend = page.getByRole('link', { name: 'Mudar tendência' })
await expect(changeTrend).toBeVisible()
await expect(changeTrend).toContainText('Mudar tendência')
const search = page.getByLabel('Buscar na campanha')
await search.focus()
await expect(page.getByRole('link', { name: 'Mudar tendência' })).toBeHidden()
const handle = page.getByRole('button', { name: 'Ocultar ações rápidas' })
const handleBox = await handle.boundingBox()
expect(handleBox).toBeTruthy()
expect(handleBox!.y).toBeLessThan(40)
await expect(page.getByRole('region', { name: 'Sugestões' })).toBeVisible()
await expect(
page.getByRole('region', { name: 'Sugestões' }).getByText(municipality.name, { exact: true }),
).toHaveCount(0)
})
})

View file

@ -91,14 +91,13 @@ describe('CampaignHomeActionButton', () => {
expect(screen.getByLabelText('Em breve').getAttribute('aria-disabled')).toBe('true')
})
it('uses horizontal padding for hit area without shrinking the visual column (B101)', () => {
it('uses a wider control column for two-line labels (B109)', () => {
renderActionButton({ label: 'Registrar', icon: BarChart3 })
const control = screen.getByRole('button', { name: 'Registrar' })
expect(control.className).toContain('px-2')
expect(control.className).toContain('box-content')
expect(control.className).toContain('w-[4.75rem]')
expect(actionControlClassName).toContain('px-2')
expect(control.className).toContain('w-[5.5rem]')
expect(actionControlClassName).toContain('box-content')
expect(actionControlClassName).toContain('w-[5.5rem]')
})
it('opens the description drawer on long-press when the pointer is coarse', () => {

View file

@ -4,6 +4,7 @@ import type { ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CampaignGlobalSearchProvider } from '@/components/campaign/dashboard/CampaignGlobalSearchMount'
import { CampaignQuickActionContextProvider } from '@/components/campaign/shell/CampaignQuickActionContext'
import { CampaignQuickActionsDrawer } from '@/components/campaign/shell/CampaignQuickActionsDrawer'
import { CampaignContentScroll } from '@/components/campaign/shell/CampaignQuickActionsHost'
import {
@ -17,8 +18,10 @@ import {
QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX,
QUICK_ACTIONS_SNAP_COLLAPSED,
QUICK_ACTIONS_SNAP_DOCK,
QUICK_ACTIONS_SNAP_FULL,
quickActionsScrollDirection,
quickActionsSnapIsDock,
quickActionsSnapIsFull,
} from '@/lib/campaignQuickActionSnap'
vi.mock('@/lib/campaignJsonRequest', () => ({
@ -89,9 +92,11 @@ afterEach(() => {
const renderQuickActionsChrome = (ui: ReactNode) =>
render(
<TooltipProvider delayDuration={300}>
<CampaignQuickActionsSnapProvider>
<CampaignGlobalSearchProvider>{ui}</CampaignGlobalSearchProvider>
</CampaignQuickActionsSnapProvider>
<CampaignQuickActionContextProvider>
<CampaignQuickActionsSnapProvider>
<CampaignGlobalSearchProvider>{ui}</CampaignGlobalSearchProvider>
</CampaignQuickActionsSnapProvider>
</CampaignQuickActionContextProvider>
</TooltipProvider>,
)
@ -241,7 +246,7 @@ describe('CampaignQuickActionsDrawer (B105)', () => {
})
})
it('expands to dock when the peek search is focused', () => {
it('expands to full snap when the peek search is focused (B109)', () => {
vi.mocked(postCampaignJson).mockResolvedValue({
ok: true,
payload: idleSuggestPayload,
@ -255,10 +260,10 @@ describe('CampaignQuickActionsDrawer (B105)', () => {
)
fireEvent.focus(screen.getByLabelText('Buscar na campanha'))
expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe('dock')
expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe('full')
})
it('stays docked while search remains focused even if the handle collapses', () => {
it('stays full while search remains focused even if the handle collapses (B109)', () => {
vi.mocked(postCampaignJson).mockResolvedValue({
ok: true,
payload: idleSuggestPayload,
@ -268,7 +273,7 @@ describe('CampaignQuickActionsDrawer (B105)', () => {
fireEvent.focus(screen.getByLabelText('Buscar na campanha'))
fireEvent.click(screen.getByRole('button', { name: 'Ocultar ações rápidas' }))
expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe('dock')
expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe('full')
})
it('toggles between dock and collapsed from the handle', () => {
@ -288,6 +293,35 @@ describe('CampaignQuickActionsDrawer (B105)', () => {
fireEvent.click(screen.getByRole('button', { name: 'Mostrar ações rápidas' }))
expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe('dock')
})
it('hides the current municipality from drawer suggest hits (B109)', async () => {
vi.mocked(postCampaignJson).mockResolvedValue({
ok: true,
payload: {
...suggestWithPriorityPayload,
municipalities: [
...suggestWithPriorityPayload.municipalities,
{
kind: 'municipality' as const,
slug: 'foo',
name: 'Município Atual',
region: 'Teste',
priority: null,
votePosition2022: null,
},
],
},
})
renderQuickActionsChrome(<CampaignQuickActionsDrawer actions={[]} />)
fireEvent.focus(screen.getByLabelText('Buscar na campanha'))
await waitFor(() => {
expect(screen.getByText('Cairu')).toBeTruthy()
expect(screen.queryByText('Município Atual')).toBeNull()
})
})
})
describe('CampaignContentScroll quick-actions direction (B105)', () => {
@ -322,10 +356,13 @@ describe('CampaignContentScroll quick-actions direction (B105)', () => {
})
describe('campaignQuickActionSnap', () => {
it('detects dock snap', () => {
it('detects dock and full snaps', () => {
expect(quickActionsSnapIsDock(QUICK_ACTIONS_SNAP_DOCK)).toBe(true)
expect(quickActionsSnapIsDock(QUICK_ACTIONS_SNAP_COLLAPSED)).toBe(false)
expect(quickActionsSnapIsDock(QUICK_ACTIONS_SNAP_FULL)).toBe(false)
expect(quickActionsSnapIsDock(null)).toBe(false)
expect(quickActionsSnapIsFull(QUICK_ACTIONS_SNAP_FULL)).toBe(true)
expect(quickActionsSnapIsFull(QUICK_ACTIONS_SNAP_DOCK)).toBe(false)
})
it('resolves scroll direction from delta threshold', () => {

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits'
import {
filterHomeSearchResponseForContext,
resolveHomeSearchExcludeContext,
} from '@/lib/homeSearchExcludeCurrentEntity'
const sampleResponse = (): HomeSearchSuccessResponse => ({
status: 'success',
resultKind: 'suggest',
municipalities: [
{
kind: 'municipality',
slug: 'cairu',
name: 'Cairu',
region: 'Baixo Sul',
priority: null,
votePosition2022: null,
},
{
kind: 'municipality',
slug: 'feira-de-santana',
name: 'Feira de Santana',
region: 'Centro-Norte',
priority: null,
votePosition2022: null,
},
],
territories: [],
advisors: [{ id: 9, name: 'Assessor', phone: null, municipalityCount: 2 }],
leaderships: [
{ kind: 'leadership', id: 42, name: 'Líder', phone: null, municipalitiesSummary: '' },
],
stateDeputies: [],
activities: [{ id: 1, slug: 'comicio', title: 'Comício', secondary: '' }],
demands: [{ id: 2, slug: 'pedido', title: 'Pedido', secondary: '' }],
})
describe('homeSearchExcludeCurrentEntity (B109)', () => {
it('merges municipality slug from pathname when context is empty', () => {
expect(resolveHomeSearchExcludeContext('/campanha/municipios/cairu', {}).municipalitySlug).toBe(
'cairu',
)
})
it('filters the current municipality from suggest and search hits', () => {
const filtered = filterHomeSearchResponseForContext(sampleResponse(), {
municipalitySlug: 'cairu',
})
expect(filtered.municipalities.map((hit) => hit.slug)).toEqual(['feira-de-santana'])
})
it('filters leadership, advisor, activity and demand entities', () => {
const filtered = filterHomeSearchResponseForContext(sampleResponse(), {
leadershipId: 42,
advisorId: 9,
activitySlug: 'comicio',
demandSlug: 'pedido',
})
expect(filtered.leaderships).toHaveLength(0)
expect(filtered.advisors).toHaveLength(0)
expect(filtered.activities).toHaveLength(0)
expect(filtered.demands).toHaveLength(0)
})
})