From 52c402c049e3595a50335133f49d82df5d520d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Fri, 24 Jul 2026 00:38:40 -0300 Subject: [PATCH] Fix the three leader-flow bugs the e2e suite pinned + RSC prop contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E-driven fixes (the suite now passes 9/9; two journeys remain latency-flaky on cold dev compiles and pass on retry): - leader home crashed with Forbidden since the remodel: loadLeaderContactsPageData loaded municipality options through the staff-scoped loader (leaders are denied municipality reads); their scope now derives from the engaged leadership's linked municipalities - leaders saw 'Contato — —' instead of their supporters' names: getAccessibleContactIds now mirrors canReadSupporter for leaders (contacts of supporters they created) - leader nav had two items with the same /campanha href (duplicate React keys); the leader home IS the contact tool — one entry - RSC serialization contracts: CampaignListPagination is a server component again (hrefForPage stays server-side; CampaignTransitionAnchor client leaf intercepts clicks with serializable props); CampaignSearchForm takes basePath instead of a function prop - hasAnyEstimate moved from the 'use client' PledgeEstimateForm to voteEstimate.ts — server components may not call client-module exports - playwright.config honors TEQO_TEST_DATABASE_URL (parity with vitest.setup.ts) and uses a fixed .next/e2e dist dir so Next stops appending per-PID entries to tsconfig include; report artifacts eslint-ignored --- eslint.config.mjs | 3 + playwright.config.ts | 16 +- .../campanha/(app)/dobradinhas/page.tsx | 4 +- .../campanha/(app)/liderancas/page.tsx | 4 +- .../campanha/(app)/organizacoes/page.tsx | 6 +- .../campaign/CampaignListPagination.tsx | 146 +++++++++--------- .../campaign/CampaignListPending.tsx | 43 ++++++ .../campaign/CampaignSearchForm.tsx | 8 +- .../campaign/MunicipalityPledgesPanel.tsx | 8 +- .../campaign/PledgeEstimateForm.tsx | 5 - src/components/campaign/nav.ts | 3 +- src/utilities/access/contacts.ts | 17 +- src/utilities/leaderContactsPageData.ts | 30 +++- src/utilities/voteEstimate.ts | 4 + tsconfig.json | 3 +- 15 files changed, 195 insertions(+), 105 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 8f4c77c3..cf8b4432 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -183,6 +183,9 @@ const eslintConfig = [ 'out/**', 'build/**', 'next-env.d.ts', + // Playwright artifacts (generated on e2e failures/reports): + 'playwright-report/**', + 'test-results/**', ]), ] diff --git a/playwright.config.ts b/playwright.config.ts index 81bfbe55..3d41b74e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,6 +9,13 @@ import { assertTestDatabase } from './tests/helpers/assertTestDatabase' * `override: true` guarantees the test DB wins over anything in the shell. */ loadEnv({ path: '.env.test', override: true }) + +// Optional local override when the default Docker port is occupied (e.g. mapped +// to 5433) — same escape hatch vitest.setup.ts documents. +if (process.env.TEQO_TEST_DATABASE_URL) { + process.env.DATABASE_URL = process.env.TEQO_TEST_DATABASE_URL +} + assertTestDatabase(process.env.DATABASE_URL) const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000' const webServerPort = new URL(baseURL).port || (baseURL.startsWith('https:') ? '443' : '80') @@ -77,9 +84,12 @@ export default defineConfig({ env: { DATABASE_URL: process.env.DATABASE_URL as string, PORT: webServerPort, - // A prior Playwright process can finish tearing down after the next starts. - // Per-process output prevents that cleanup from deleting the active server's files. - NEXT_DIST_DIR: `.next/e2e-${process.pid}`, + // Fixed dist dir: Next dev appends `/types/**/*.ts` to + // tsconfig.json's include with an EXACT-string check, so per-process + // names polluted tsconfig with one entry per run. A prior Playwright + // process tearing down late can, rarely, delete files of the next run's + // server — if a boot flakes, rerun (dev-only trade-off). + NEXT_DIST_DIR: '.next/e2e', PAYLOAD_SECRET: process.env.PAYLOAD_SECRET ?? 'test-only-secret-not-used-in-production', NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL ?? baseURL, }, diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx index d1b8284b..321525ef 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx @@ -79,9 +79,7 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP ariaLabel="Buscar dobradinha por nome" placeholder="Buscar por nome…" initialQuery={state.q ?? ''} - hrefForQuery={(query) => - query ? `/campanha/dobradinhas?q=${encodeURIComponent(query)}` : '/campanha/dobradinhas' - } + basePath="/campanha/dobradinhas" /> diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx index 1b3c4d36..f13a69b5 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx @@ -77,9 +77,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP ariaLabel="Buscar liderança por nome" placeholder="Buscar por nome…" initialQuery={state.q ?? ''} - hrefForQuery={(query) => - query ? `/campanha/liderancas?q=${encodeURIComponent(query)}` : '/campanha/liderancas' - } + basePath="/campanha/liderancas" /> diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx b/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx index 29095e35..efa10de2 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx +++ b/src/app/(campaign)/campanha/(app)/organizacoes/page.tsx @@ -82,11 +82,7 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP ariaLabel="Buscar organização por nome" placeholder="Buscar por nome…" initialQuery={state.q ?? ''} - hrefForQuery={(query) => - query - ? `/campanha/organizacoes?q=${encodeURIComponent(query)}` - : '/campanha/organizacoes' - } + basePath="/campanha/organizacoes" /> diff --git a/src/components/campaign/CampaignListPagination.tsx b/src/components/campaign/CampaignListPagination.tsx index cb3a4f70..bc39bbea 100644 --- a/src/components/campaign/CampaignListPagination.tsx +++ b/src/components/campaign/CampaignListPagination.tsx @@ -1,18 +1,14 @@ -'use client' +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react' -import { useRouter } from 'next/navigation' -import { useTransition, type MouseEvent } from 'react' - -import { useCampaignListPending } from '@/components/campaign/CampaignListPending' +import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending' +import { Button } from '@/components/ui/button' import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, } from '@/components/ui/Pagination' +import { cn } from '@/lib/utils' export const getPaginationPages = (page: number, totalPages: number): number[] => { const pages = new Set([1, totalPages, page - 1, page, page + 1]) @@ -21,6 +17,12 @@ export const getPaginationPages = (page: number, totalPages: number): number[] = .sort((a, b) => a - b) } +/** + * Server component: `hrefForPage` never crosses the client boundary. The + * anchors themselves are `CampaignTransitionAnchor` client leaves so page + * changes ride the shared list transition (results dim) instead of a dead + * full-page load. + */ export const CampaignListPagination = ({ page, totalPages, @@ -30,83 +32,83 @@ export const CampaignListPagination = ({ totalPages: number hrefForPage: (page: number) => string }) => { - const router = useRouter() - const shared = useCampaignListPending() - const [isLocalPending, startLocalTransition] = useTransition() - const isPending = shared?.isPending ?? isLocalPending - const startTransition = shared?.startTransition ?? startLocalTransition - if (totalPages <= 1) return null const visiblePages = getPaginationPages(page, totalPages) - /** Plain left-clicks navigate in a transition (pending UI); modified clicks keep link semantics. */ - const interceptNavigation = (event: MouseEvent, href: string) => { - if ( - event.defaultPrevented || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) { - return - } - event.preventDefault() - startTransition(() => { - router.push(href) - }) - } - return ( -
-

- {isPending ? 'Carregando página…' : ''} -

- - - - + + + + + {visiblePages.map((pageNumber, index) => { + const previousPage = visiblePages[index - 1] + const hasGap = previousPage !== undefined && pageNumber - previousPage > 1 + const isActive = pageNumber === page - return ( - - {hasGap ? : null} - + {hasGap ? : null} + + + ) + })} + +
+ data-slot="pagination-link" + > + Próxima + + + + + + ) } diff --git a/src/components/campaign/CampaignListPending.tsx b/src/components/campaign/CampaignListPending.tsx index 447aca66..fb28de85 100644 --- a/src/components/campaign/CampaignListPending.tsx +++ b/src/components/campaign/CampaignListPending.tsx @@ -1,9 +1,11 @@ 'use client' +import { useRouter } from 'next/navigation' import { createContext, useContext, useTransition, + type ComponentPropsWithoutRef, type ReactNode, type TransitionStartFunction, } from 'react' @@ -38,6 +40,47 @@ export const CampaignListPendingBoundary = ({ children }: { children: ReactNode export const useCampaignListPending = (): CampaignListPendingValue | null => useContext(CampaignListPendingContext) +/** + * Anchor that navigates through the shared list transition (falling back to a + * local one), so the results region dims instead of a dead full-page load. + * Takes only serializable props — safe to render from server components. + */ +export const CampaignTransitionAnchor = ({ + href, + children, + ...anchorProps +}: Omit, 'href' | 'onClick'> & { href: string }) => { + const router = useRouter() + const shared = useContext(CampaignListPendingContext) + const [, startLocalTransition] = useTransition() + const startTransition = shared?.startTransition ?? startLocalTransition + + return ( + { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return + } + event.preventDefault() + startTransition(() => { + router.push(href) + }) + }} + > + {children} + + ) +} + /** Wraps the server-rendered results; dims while a shared navigation is pending. */ export const CampaignListResults = ({ children, diff --git a/src/components/campaign/CampaignSearchForm.tsx b/src/components/campaign/CampaignSearchForm.tsx index fad28534..fb338aa5 100644 --- a/src/components/campaign/CampaignSearchForm.tsx +++ b/src/components/campaign/CampaignSearchForm.tsx @@ -15,12 +15,13 @@ export const CampaignSearchForm = ({ ariaLabel, placeholder, initialQuery, - hrefForQuery, + basePath, }: { ariaLabel: string placeholder: string initialQuery: string - hrefForQuery: (query: string) => string + /** List route; the query lands as `?q=` (serializable — no function props across the RSC boundary). */ + basePath: string }) => { const router = useRouter() const shared = useCampaignListPending() @@ -29,6 +30,9 @@ export const CampaignSearchForm = ({ const startTransition = shared?.startTransition ?? startLocalTransition const [query, setQuery] = useState(initialQuery) + const hrefForQuery = (value: string) => + value ? `${basePath}?q=${encodeURIComponent(value)}` : basePath + return (
{ +export const MunicipalityPledgesPanel = ({ + pledges, + estimateFormAction, +}: MunicipalityPledgesPanelProps) => { const declaredTotal = pledges.reduce((total, pledge) => total + pledge.declaredVotes, 0) const effectiveTotal = pledges.reduce( (total, pledge) => diff --git a/src/components/campaign/PledgeEstimateForm.tsx b/src/components/campaign/PledgeEstimateForm.tsx index e6058922..ca368961 100644 --- a/src/components/campaign/PledgeEstimateForm.tsx +++ b/src/components/campaign/PledgeEstimateForm.tsx @@ -22,9 +22,6 @@ type PledgeEstimateFormProps = { ) => Promise } -const hasAnyEstimate = (estimates: VoteEstimateScenarioViewModel): boolean => - estimates.pessimistic != null || estimates.central != null || estimates.optimistic != null - /** Staff-only inline estimate. The leader never sees these fields. */ export const PledgeEstimateForm = ({ pledgeID, @@ -82,5 +79,3 @@ export const PledgeEstimateForm = ({ ) } - -export { hasAnyEstimate } diff --git a/src/components/campaign/nav.ts b/src/components/campaign/nav.ts index 5d5d79f2..77231ffe 100644 --- a/src/components/campaign/nav.ts +++ b/src/components/campaign/nav.ts @@ -30,7 +30,8 @@ const staffNav: CampaignNavItem[] = [ ] const leaderNav: CampaignNavItem[] = [ - { title: 'Início', href: '/campanha', icon: HomeIcon }, + // The leader home IS the contact tool — one entry, one href (duplicate + // hrefs previously produced duplicate React keys in the sidebar). { title: 'Meus contatos', href: '/campanha', icon: UsersIcon }, ] diff --git a/src/utilities/access/contacts.ts b/src/utilities/access/contacts.ts index 026e7a6e..b655bf71 100644 --- a/src/utilities/access/contacts.ts +++ b/src/utilities/access/contacts.ts @@ -68,7 +68,16 @@ export const getAccessibleContactIds = async ( } } - if (currentUser.role === 'advisor' && collections.supporter) { + // Advisors read contacts of supporters in their municipalities; leaders read + // contacts of the supporters they created — mirroring `canReadSupporter`. + const supporterWhere = + currentUser.role === 'advisor' + ? { municipality: { in: municipalityIDs ?? [] } } + : currentUser.role === 'leader' + ? { createdBy: { equals: currentUser.id } } + : null + + if (supporterWhere && collections.supporter) { const supporterResult = await find({ collection: 'supporter', depth: 0, @@ -77,11 +86,7 @@ export const getAccessibleContactIds = async ( pagination: false, req, select: { contact: true }, - where: { - municipality: { - in: municipalityIDs ?? [], - }, - }, + where: supporterWhere, }) for (const doc of supporterResult.docs) { const id = relationshipId(doc.contact) diff --git a/src/utilities/leaderContactsPageData.ts b/src/utilities/leaderContactsPageData.ts index d8b7bed9..c68b8d05 100644 --- a/src/utilities/leaderContactsPageData.ts +++ b/src/utilities/leaderContactsPageData.ts @@ -2,8 +2,8 @@ import type { Payload } from 'payload' import type { RelationOption } from '@/components/campaign/RelationMultiSelect' import type { CampaignUser, Contact, Municipality } from '@/payload-types' +import { getEngagedLeaderMunicipalityIds } from '@/utilities/campaignAccess' import { getSupporterRegistrationConsent } from '@/utilities/campaignConsent' -import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions' export type LeaderContactListItem = { id: number @@ -42,12 +42,38 @@ const toLeaderContactListItem = (supporter: { } } +/** + * Leaders cannot read the municipality collection (lockdown), so their + * registration scope comes from the engaged leadership's linked municipality + * IDs — the same scope `canCreateSupporter` enforces on the write path. + */ +const loadLeaderMunicipalityOptions = async ( + payload: Payload, + user: CampaignUser, +): Promise => { + const ids = await getEngagedLeaderMunicipalityIds(payload, user.id) + if (ids.length === 0) return [] + + const result = await payload.find({ + collection: 'municipality', + where: { id: { in: ids } }, + depth: 0, + limit: 0, + pagination: false, + sort: 'name', + select: { name: true }, + // Intentional bypass: scope already derived from the leader's own leadership. + overrideAccess: true, + }) + return result.docs.map((municipality) => ({ id: municipality.id, name: municipality.name })) +} + export const loadLeaderContactsPageData = async ( payload: Payload, user: CampaignUser, ): Promise => { const [municipalityOptions, registrationConsent, supporters] = await Promise.all([ - loadMunicipalityOptions(payload, user), + loadLeaderMunicipalityOptions(payload, user), getSupporterRegistrationConsent(payload), payload.find({ collection: 'supporter', diff --git a/src/utilities/voteEstimate.ts b/src/utilities/voteEstimate.ts index 611db6bb..90cb8b77 100644 --- a/src/utilities/voteEstimate.ts +++ b/src/utilities/voteEstimate.ts @@ -16,6 +16,10 @@ export type VoteEstimateScenarioViewModel = { optimistic: number | null } +/** True when at least one scenario has a recorded estimate. */ +export const hasAnyEstimate = (estimates: VoteEstimateScenarioViewModel): boolean => + estimates.pessimistic != null || estimates.central != null || estimates.optimistic != null + export const voteEstimateScenarioLabels: Record = { pessimistic: 'Pessimista', central: 'Média', diff --git a/tsconfig.json b/tsconfig.json index c0f4b655..3841eb0b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,7 +37,8 @@ "**/*.tsx", "next-env.d.ts", ".next/types/**/*.ts", - ".next/*/types/**/*.ts" + ".next/dev/types/**/*.ts", + ".next/e2e/types/**/*.ts" ], "exclude": [ "node_modules"