From 490d5deeb7072a49e69284879a37aa50a2782e91 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 20:38:10 +0000 Subject: [PATCH 1/3] =?UTF-8?q?B109=20=E2=80=94=20bottom=20drawer:=20fulls?= =?UTF-8?q?creen=20search,=20wider=20labels,=20exclude=20current=20entity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add QUICK_ACTIONS_SNAP_FULL; drawer expands on search focus and hides action strip - Tighten dock bottom gap; restore dock/collapsed on blur per scroll position (B100) - Widen CampaignHomeActionButton to w-[5.5rem] for two-line labels - Client-side filter excludes current page entity from drawer search hits - Unit + e2e coverage for full snap, exclusion, and label readability --- .../dashboard/CampaignHomeActionButton.tsx | 2 +- .../dashboard/HomeSearchExcludeContext.tsx | 20 +++++ .../dashboard/HomeSearchResultsContext.tsx | 17 +++- .../shell/CampaignQuickActionsDrawer.tsx | 83 ++++++++++++++----- .../shell/CampaignQuickActionsHost.tsx | 8 +- .../shell/CampaignQuickActionsSnapContext.tsx | 3 + src/lib/campaignQuickActionContext.ts | 1 + src/lib/campaignQuickActionSnap.ts | 7 ++ src/lib/homeSearchExcludeCurrentEntity.ts | 83 +++++++++++++++++++ tests/e2e/campaignMunicipalities.e2e.spec.ts | 41 +++++++++ .../campaignHomeActionButton.unit.spec.tsx | 7 +- .../campaignQuickActionsDrawer.unit.spec.tsx | 53 ++++++++++-- ...omeSearchExcludeCurrentEntity.unit.spec.ts | 64 ++++++++++++++ 13 files changed, 352 insertions(+), 37 deletions(-) create mode 100644 src/components/campaign/dashboard/HomeSearchExcludeContext.tsx create mode 100644 src/lib/homeSearchExcludeCurrentEntity.ts create mode 100644 tests/unit/homeSearchExcludeCurrentEntity.unit.spec.ts diff --git a/src/components/campaign/dashboard/CampaignHomeActionButton.tsx b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx index f65082ca..44273173 100644 --- a/src/components/campaign/dashboard/CampaignHomeActionButton.tsx +++ b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx @@ -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' diff --git a/src/components/campaign/dashboard/HomeSearchExcludeContext.tsx b/src/components/campaign/dashboard/HomeSearchExcludeContext.tsx new file mode 100644 index 00000000..c2ef215b --- /dev/null +++ b/src/components/campaign/dashboard/HomeSearchExcludeContext.tsx @@ -0,0 +1,20 @@ +'use client' + +import { createContext, useContext, type ReactNode } from 'react' + +import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext' + +const HomeSearchExcludeContext = createContext(null) + +export const HomeSearchExcludeProvider = ({ + value, + children, +}: { + value: CampaignQuickActionContext + children: ReactNode +}) => ( + {children} +) + +export const useHomeSearchExcludeContext = (): CampaignQuickActionContext | null => + useContext(HomeSearchExcludeContext) diff --git a/src/components/campaign/dashboard/HomeSearchResultsContext.tsx b/src/components/campaign/dashboard/HomeSearchResultsContext.tsx index d92a3415..b1d868d1 100644 --- a/src/components/campaign/dashboard/HomeSearchResultsContext.tsx +++ b/src/components/campaign/dashboard/HomeSearchResultsContext.tsx @@ -11,8 +11,10 @@ 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 { filterHomeSearchResponseForContext } from '@/lib/homeSearchExcludeCurrentEntity' import { postCampaignJson } from '@/lib/campaignJsonRequest' 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({ 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], ) } diff --git a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx b/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx index cc6ec09f..d2194d38 100644 --- a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx @@ -1,36 +1,59 @@ 'use client' +import { usePathname } from 'next/navigation' import { useCallback, useEffect } from 'react' import { CampaignGlobalSearchBody } from '@/components/campaign/dashboard/CampaignGlobalSearchMount' +import { HomeSearchExcludeProvider } from '@/components/campaign/dashboard/HomeSearchExcludeContext' import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip' import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext' +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,12 +79,17 @@ 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 ( Ações rápidas
{showActions ? ( - diff --git a/src/components/campaign/shell/CampaignQuickActionsHost.tsx b/src/components/campaign/shell/CampaignQuickActionsHost.tsx index 4ef05d99..c2f46818 100644 --- a/src/components/campaign/shell/CampaignQuickActionsHost.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsHost.tsx @@ -21,9 +21,13 @@ import { cn } from '@/lib/utils' const CampaignContentScrollWithPeek = ({ children }: { children: ReactNode }) => { const scrollRef = useRef(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 diff --git a/src/components/campaign/shell/CampaignQuickActionsSnapContext.tsx b/src/components/campaign/shell/CampaignQuickActionsSnapContext.tsx index 14c89130..d2751b40 100644 --- a/src/components/campaign/shell/CampaignQuickActionsSnapContext.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsSnapContext.tsx @@ -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> isDock: boolean + isFull: boolean } const CampaignQuickActionsSnapContext = createContext( @@ -41,6 +43,7 @@ export const CampaignQuickActionsSnapProvider = ({ children }: { children: React snapPoint, setSnapPoint, isDock: quickActionsSnapIsDock(snapPoint), + isFull: quickActionsSnapIsFull(snapPoint), }), [snapPoint], ) diff --git a/src/lib/campaignQuickActionContext.ts b/src/lib/campaignQuickActionContext.ts index fdfb94df..a3290573 100644 --- a/src/lib/campaignQuickActionContext.ts +++ b/src/lib/campaignQuickActionContext.ts @@ -9,6 +9,7 @@ export type CampaignQuickActionContext = { organizationSlug?: string activitySlug?: string demandSlug?: string + advisorId?: number } export const emptyCampaignQuickActionContext = (): CampaignQuickActionContext => ({}) diff --git a/src/lib/campaignQuickActionSnap.ts b/src/lib/campaignQuickActionSnap.ts index 12bba97d..0f366f81 100644 --- a/src/lib/campaignQuickActionSnap.ts +++ b/src/lib/campaignQuickActionSnap.ts @@ -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 diff --git a/src/lib/homeSearchExcludeCurrentEntity.ts b/src/lib/homeSearchExcludeCurrentEntity.ts new file mode 100644 index 00000000..3fc6bbbe --- /dev/null +++ b/src/lib/homeSearchExcludeCurrentEntity.ts @@ -0,0 +1,83 @@ +import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits' +import { parseAdvisorDetailId } from '@/lib/campaignAdvisorQuickActions' +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, + } +} diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index 1429411e..b710bf9e 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -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) + }) +}) diff --git a/tests/unit/campaignHomeActionButton.unit.spec.tsx b/tests/unit/campaignHomeActionButton.unit.spec.tsx index 2646d024..66b29018 100644 --- a/tests/unit/campaignHomeActionButton.unit.spec.tsx +++ b/tests/unit/campaignHomeActionButton.unit.spec.tsx @@ -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', () => { diff --git a/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx b/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx index 9611cc3c..1e579dcd 100644 --- a/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx +++ b/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CampaignGlobalSearchProvider } from '@/components/campaign/dashboard/CampaignGlobalSearchMount' import { CampaignQuickActionsDrawer } from '@/components/campaign/shell/CampaignQuickActionsDrawer' +import { CampaignQuickActionContextProvider } from '@/components/campaign/shell/CampaignQuickActionContext' import { CampaignContentScroll } from '@/components/campaign/shell/CampaignQuickActionsHost' import { CampaignQuickActionsSnapProvider, @@ -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( - - {ui} - + + + {ui} + + , ) @@ -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() + + 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', () => { diff --git a/tests/unit/homeSearchExcludeCurrentEntity.unit.spec.ts b/tests/unit/homeSearchExcludeCurrentEntity.unit.spec.ts new file mode 100644 index 00000000..196d5ea6 --- /dev/null +++ b/tests/unit/homeSearchExcludeCurrentEntity.unit.spec.ts @@ -0,0 +1,64 @@ +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) + }) +}) From fcd0206da6b0049ddd08624935543bbee375c4bf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 20:38:53 +0000 Subject: [PATCH 2/3] chore: prettier format (B109) --- .../campaign/dashboard/HomeSearchResultsContext.tsx | 2 +- .../campaign/shell/CampaignQuickActionsDrawer.tsx | 6 ++---- src/lib/homeSearchExcludeCurrentEntity.ts | 12 ++++++------ tests/unit/campaignQuickActionsDrawer.unit.spec.tsx | 2 +- .../unit/homeSearchExcludeCurrentEntity.unit.spec.ts | 10 ++++++---- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/components/campaign/dashboard/HomeSearchResultsContext.tsx b/src/components/campaign/dashboard/HomeSearchResultsContext.tsx index b1d868d1..e04ab4bf 100644 --- a/src/components/campaign/dashboard/HomeSearchResultsContext.tsx +++ b/src/components/campaign/dashboard/HomeSearchResultsContext.tsx @@ -14,8 +14,8 @@ 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 { filterHomeSearchResponseForContext } from '@/lib/homeSearchExcludeCurrentEntity' import { postCampaignJson } from '@/lib/campaignJsonRequest' +import { filterHomeSearchResponseForContext } from '@/lib/homeSearchExcludeCurrentEntity' const HOME_SEARCH_ROUTE = '/campanha/home-search' diff --git a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx b/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx index d2194d38..828731ee 100644 --- a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx @@ -4,9 +4,9 @@ import { usePathname } from 'next/navigation' import { useCallback, useEffect } from 'react' import { CampaignGlobalSearchBody } from '@/components/campaign/dashboard/CampaignGlobalSearchMount' -import { HomeSearchExcludeProvider } from '@/components/campaign/dashboard/HomeSearchExcludeContext' 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' @@ -116,9 +116,7 @@ export const CampaignQuickActionsDrawer = ({ 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-0.5 active:cursor-grabbing" - aria-label={ - isDock || isFull ? 'Ocultar ações rápidas' : 'Mostrar ações rápidas' - } + aria-label={isDock || isFull ? 'Ocultar ações rápidas' : 'Mostrar ações rápidas'} > diff --git a/src/lib/homeSearchExcludeCurrentEntity.ts b/src/lib/homeSearchExcludeCurrentEntity.ts index 3fc6bbbe..057b3276 100644 --- a/src/lib/homeSearchExcludeCurrentEntity.ts +++ b/src/lib/homeSearchExcludeCurrentEntity.ts @@ -1,5 +1,5 @@ -import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits' 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' @@ -47,11 +47,11 @@ export const resolveHomeSearchExcludeContext = ( const hasExcludeTarget = (context: CampaignQuickActionContext): boolean => Boolean( context.municipalitySlug || - context.leadershipId !== undefined || - context.organizationSlug || - context.activitySlug || - context.demandSlug || - context.advisorId !== undefined, + context.leadershipId !== undefined || + context.organizationSlug || + context.activitySlug || + context.demandSlug || + context.advisorId !== undefined, ) export const filterHomeSearchResponseForContext = ( diff --git a/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx b/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx index 1e579dcd..c1fc0452 100644 --- a/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx +++ b/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx @@ -4,8 +4,8 @@ import type { ReactNode } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CampaignGlobalSearchProvider } from '@/components/campaign/dashboard/CampaignGlobalSearchMount' -import { CampaignQuickActionsDrawer } from '@/components/campaign/shell/CampaignQuickActionsDrawer' import { CampaignQuickActionContextProvider } from '@/components/campaign/shell/CampaignQuickActionContext' +import { CampaignQuickActionsDrawer } from '@/components/campaign/shell/CampaignQuickActionsDrawer' import { CampaignContentScroll } from '@/components/campaign/shell/CampaignQuickActionsHost' import { CampaignQuickActionsSnapProvider, diff --git a/tests/unit/homeSearchExcludeCurrentEntity.unit.spec.ts b/tests/unit/homeSearchExcludeCurrentEntity.unit.spec.ts index 196d5ea6..a094bbe9 100644 --- a/tests/unit/homeSearchExcludeCurrentEntity.unit.spec.ts +++ b/tests/unit/homeSearchExcludeCurrentEntity.unit.spec.ts @@ -29,7 +29,9 @@ const sampleResponse = (): HomeSearchSuccessResponse => ({ ], territories: [], advisors: [{ id: 9, name: 'Assessor', phone: null, municipalityCount: 2 }], - leaderships: [{ kind: 'leadership', id: 42, name: 'Líder', phone: null, municipalitiesSummary: '' }], + 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: '' }], @@ -37,9 +39,9 @@ const sampleResponse = (): HomeSearchSuccessResponse => ({ describe('homeSearchExcludeCurrentEntity (B109)', () => { it('merges municipality slug from pathname when context is empty', () => { - expect( - resolveHomeSearchExcludeContext('/campanha/municipios/cairu', {}).municipalitySlug, - ).toBe('cairu') + expect(resolveHomeSearchExcludeContext('/campanha/municipios/cairu', {}).municipalitySlug).toBe( + 'cairu', + ) }) it('filters the current municipality from suggest and search hits', () => { From ae3cfffb5aa224e6ec2aa52fd9d0c3d74abf5bc0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 20:49:32 +0000 Subject: [PATCH 3/3] fix(B109): fullscreen drawer uses dedicated snap + 100dvh height --- .../campaign/shell/CampaignQuickActionsDrawer.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx b/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx index 828731ee..b5137564 100644 --- a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx @@ -96,8 +96,8 @@ export const CampaignQuickActionsDrawer = ({ 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 @@ -106,7 +106,7 @@ export const CampaignQuickActionsDrawer = ({ id="CampaignQuickActionsDrawer" 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]', + isFull && 'z-[60] h-dvh max-h-dvh [--drawer-height:100dvh]', )} > Ações rápidas