mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
Fix the three leader-flow bugs the e2e suite pinned + RSC prop contracts
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
This commit is contained in:
parent
e79f60822e
commit
52c402c049
15 changed files with 195 additions and 105 deletions
|
|
@ -183,6 +183,9 @@ const eslintConfig = [
|
|||
'out/**',
|
||||
'build/**',
|
||||
'next-env.d.ts',
|
||||
// Playwright artifacts (generated on e2e failures/reports):
|
||||
'playwright-report/**',
|
||||
'test-results/**',
|
||||
]),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `<distDir>/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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
||||
<CampaignListResults>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
||||
<CampaignListResults>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
||||
<CampaignListResults>
|
||||
|
|
|
|||
|
|
@ -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<HTMLAnchorElement>, href: string) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
startTransition(() => {
|
||||
router.push(href)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="transition-opacity data-[pending=true]:opacity-70"
|
||||
data-pending={isPending || undefined}
|
||||
aria-busy={isPending}
|
||||
>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Carregando página…' : ''}
|
||||
</p>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="default"
|
||||
className={cn(
|
||||
'min-h-11 min-w-11 pl-1.5!',
|
||||
page <= 1 ? 'pointer-events-none opacity-50' : undefined,
|
||||
)}
|
||||
>
|
||||
<CampaignTransitionAnchor
|
||||
href={hrefForPage(Math.max(1, page - 1))}
|
||||
onClick={(event) => interceptNavigation(event, hrefForPage(Math.max(1, page - 1)))}
|
||||
aria-label="Ir para a página anterior"
|
||||
aria-disabled={page <= 1}
|
||||
className={page <= 1 ? 'pointer-events-none opacity-50' : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{visiblePages.map((pageNumber, index) => {
|
||||
const previousPage = visiblePages[index - 1]
|
||||
const hasGap = previousPage !== undefined && pageNumber - previousPage > 1
|
||||
data-slot="pagination-link"
|
||||
>
|
||||
<ChevronLeftIcon data-icon="inline-start" />
|
||||
<span className="hidden sm:block">Anterior</span>
|
||||
</CampaignTransitionAnchor>
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
{visiblePages.map((pageNumber, index) => {
|
||||
const previousPage = visiblePages[index - 1]
|
||||
const hasGap = previousPage !== undefined && pageNumber - previousPage > 1
|
||||
const isActive = pageNumber === page
|
||||
|
||||
return (
|
||||
<PaginationItem key={pageNumber} className="contents">
|
||||
{hasGap ? <PaginationEllipsis /> : null}
|
||||
<PaginationLink
|
||||
return (
|
||||
<PaginationItem key={pageNumber} className="contents">
|
||||
{hasGap ? <PaginationEllipsis /> : null}
|
||||
<Button
|
||||
asChild
|
||||
variant={isActive ? 'outline' : 'ghost'}
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
>
|
||||
<CampaignTransitionAnchor
|
||||
href={hrefForPage(pageNumber)}
|
||||
onClick={(event) => interceptNavigation(event, hrefForPage(pageNumber))}
|
||||
isActive={pageNumber === page}
|
||||
aria-label={`Ir para a página ${pageNumber}`}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
>
|
||||
{pageNumber}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
})}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
</CampaignTransitionAnchor>
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
)
|
||||
})}
|
||||
<PaginationItem>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="default"
|
||||
className={cn(
|
||||
'min-h-11 min-w-11 pr-1.5!',
|
||||
page >= totalPages ? 'pointer-events-none opacity-50' : undefined,
|
||||
)}
|
||||
>
|
||||
<CampaignTransitionAnchor
|
||||
href={hrefForPage(Math.min(totalPages, page + 1))}
|
||||
onClick={(event) =>
|
||||
interceptNavigation(event, hrefForPage(Math.min(totalPages, page + 1)))
|
||||
}
|
||||
aria-label="Ir para a próxima página"
|
||||
aria-disabled={page >= totalPages}
|
||||
className={page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
data-slot="pagination-link"
|
||||
>
|
||||
<span className="hidden sm:block">Próxima</span>
|
||||
<ChevronRightIcon data-icon="inline-end" />
|
||||
</CampaignTransitionAnchor>
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ComponentPropsWithoutRef<'a'>, 'href' | 'onClick'> & { href: string }) => {
|
||||
const router = useRouter()
|
||||
const shared = useContext(CampaignListPendingContext)
|
||||
const [, startLocalTransition] = useTransition()
|
||||
const startTransition = shared?.startTransition ?? startLocalTransition
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
{...anchorProps}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
startTransition(() => {
|
||||
router.push(href)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/** Wraps the server-rendered results; dims while a shared navigation is pending. */
|
||||
export const CampaignListResults = ({
|
||||
children,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<form
|
||||
role="search"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { hasAnyEstimate, PledgeEstimateForm } from '@/components/campaign/PledgeEstimateForm'
|
||||
import { PledgeEstimateForm } from '@/components/campaign/PledgeEstimateForm'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import {
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
getVoteEstimateForScenario,
|
||||
} from '@/utilities/voteEstimate'
|
||||
import { type StaffPledgeRow } from '@/utilities/votePledgeData'
|
||||
import { hasAnyEstimate } from '@/utilities/voteEstimate'
|
||||
|
||||
const voteFormatter = new Intl.NumberFormat('pt-BR')
|
||||
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
|
||||
|
|
@ -20,7 +21,10 @@ type MunicipalityPledgesPanelProps = {
|
|||
}
|
||||
|
||||
/** Staff-only: declared vs estimated votes per leadership in this municipality. */
|
||||
export const MunicipalityPledgesPanel = ({ pledges, estimateFormAction }: MunicipalityPledgesPanelProps) => {
|
||||
export const MunicipalityPledgesPanel = ({
|
||||
pledges,
|
||||
estimateFormAction,
|
||||
}: MunicipalityPledgesPanelProps) => {
|
||||
const declaredTotal = pledges.reduce((total, pledge) => total + pledge.declaredVotes, 0)
|
||||
const effectiveTotal = pledges.reduce(
|
||||
(total, pledge) =>
|
||||
|
|
|
|||
|
|
@ -22,9 +22,6 @@ type PledgeEstimateFormProps = {
|
|||
) => Promise<CampaignFormActionState>
|
||||
}
|
||||
|
||||
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 = ({
|
|||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export { hasAnyEstimate }
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<RelationOption[]> => {
|
||||
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<LeaderContactsPageView> => {
|
||||
const [municipalityOptions, registrationConsent, supporters] = await Promise.all([
|
||||
loadMunicipalityOptions(payload, user),
|
||||
loadLeaderMunicipalityOptions(payload, user),
|
||||
getSupporterRegistrationConsent(payload),
|
||||
payload.find({
|
||||
collection: 'supporter',
|
||||
|
|
|
|||
|
|
@ -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<VoteEstimateScenario, string> = {
|
||||
pessimistic: 'Pessimista',
|
||||
central: 'Média',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue