Skip to content

Commit c3a48cc

Browse files
fix(frontend): align paginated posts routing (#656)
1 parent 8f6929d commit c3a48cc

File tree

6 files changed

+165
-17
lines changed

6 files changed

+165
-17
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
'use client'
2+
3+
import { Pagination, type PaginationProps } from '@/components/molecules/Pagination'
4+
import { useRouter } from 'next/navigation'
5+
import React from 'react'
6+
7+
type Props = Omit<PaginationProps, 'onNavigate'>
8+
9+
export const PostsPagination: React.FC<Props> = (props) => {
10+
const router = useRouter()
11+
12+
return <Pagination {...props} onNavigate={router.push} />
13+
}

src/app/(frontend)/posts/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { Metadata } from 'next/types'
22

3-
import { Pagination } from '@/components/molecules/Pagination'
43
import configPromise from '@payload-config'
54
import { getPayload } from 'payload'
65
import React from 'react'
@@ -10,6 +9,7 @@ import { BlogHero } from '@/components/organisms/Blog/BlogHero'
109
import { BlogCard } from '@/components/organisms/Blog/BlogCard'
1110
import { normalizePost } from '@/utilities/blog/normalizePost'
1211
import { Heading } from '@/components/atoms/Heading'
12+
import { PostsPagination } from './_components/PostsPagination'
1313

1414
export const dynamic = 'force-static'
1515
export const revalidate = 600
@@ -80,7 +80,7 @@ export default async function Page() {
8080
)}
8181

8282
<Container>
83-
{posts.totalPages > 1 && posts.page && <Pagination page={posts.page} totalPages={posts.totalPages} />}
83+
{posts.totalPages > 1 && posts.page && <PostsPagination page={posts.page} totalPages={posts.totalPages} />}
8484
</Container>
8585
</div>
8686
)

src/app/(frontend)/posts/page/[pageNumber]/page.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import type { Metadata } from 'next/types'
22

33
import { CollectionArchive } from '@/components/organisms/CollectionArchive'
4-
import { Pagination } from '@/components/molecules/Pagination'
54
import configPromise from '@payload-config'
65
import { getPayload } from 'payload'
76
import React from 'react'
87
import PageClient from './page.client'
98
import { Heading } from '@/components/atoms/Heading'
10-
import { notFound } from 'next/navigation'
9+
import { notFound, redirect } from 'next/navigation'
1110
import { Container } from '@/components/molecules/Container'
1211
import { normalizePost } from '@/utilities/blog/normalizePost'
12+
import { PostsPagination } from '../../_components/PostsPagination'
1313

1414
export const revalidate = 600
15+
const POSTS_PER_PAGE = 12
1516

1617
type Args = {
1718
params: Promise<{
@@ -21,16 +22,20 @@ type Args = {
2122

2223
export default async function Page({ params: paramsPromise }: Args) {
2324
const { pageNumber } = await paramsPromise
24-
const payload = await getPayload({ config: configPromise })
2525

26-
const sanitizedPageNumber = Number(pageNumber)
26+
if (!/^\d+$/.test(pageNumber)) notFound()
27+
28+
const sanitizedPageNumber = Number.parseInt(pageNumber, 10)
29+
30+
if (!Number.isSafeInteger(sanitizedPageNumber) || sanitizedPageNumber < 1) notFound()
31+
if (sanitizedPageNumber === 1) redirect('/posts')
2732

28-
if (!Number.isInteger(sanitizedPageNumber)) notFound()
33+
const payload = await getPayload({ config: configPromise })
2934

3035
const posts = await payload.find({
3136
collection: 'posts',
3237
depth: 1,
33-
limit: 12,
38+
limit: POSTS_PER_PAGE,
3439
page: sanitizedPageNumber,
3540
overrideAccess: false,
3641
select: {
@@ -49,6 +54,9 @@ export default async function Page({ params: paramsPromise }: Args) {
4954
},
5055
},
5156
})
57+
58+
if (!posts.docs.length || sanitizedPageNumber > posts.totalPages) notFound()
59+
5260
const normalizedPosts = posts.docs.map(normalizePost)
5361
const remainingArticlesCount = Math.max((posts.totalDocs || 0) - 1, 0)
5462

@@ -75,7 +83,7 @@ export default async function Page({ params: paramsPromise }: Args) {
7583
<CollectionArchive posts={normalizedPosts} />
7684

7785
<Container>
78-
{posts?.page && posts?.totalPages > 1 && <Pagination page={posts.page} totalPages={posts.totalPages} />}
86+
{posts?.page && posts?.totalPages > 1 && <PostsPagination page={posts.page} totalPages={posts.totalPages} />}
7987
</Container>
8088
</div>
8189
)
@@ -95,11 +103,11 @@ export async function generateStaticParams() {
95103
overrideAccess: false,
96104
})
97105

98-
const totalPages = Math.ceil(totalDocs / 10)
106+
const totalPages = Math.ceil(totalDocs / POSTS_PER_PAGE)
99107

100108
const pages: { pageNumber: string }[] = []
101109

102-
for (let i = 1; i <= totalPages; i++) {
110+
for (let i = 2; i <= totalPages; i++) {
103111
pages.push({ pageNumber: String(i) })
104112
}
105113

src/components/molecules/Pagination/index.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,29 @@ import {
99
PaginationPrevious,
1010
} from '@/components/atoms/pagination'
1111
import { cn } from '@/utilities/ui'
12-
import { useRouter } from 'next/navigation'
1312
import React from 'react'
1413

15-
export const Pagination: React.FC<{
14+
export type PaginationProps = {
1615
className?: string
1716
page: number
1817
totalPages: number
1918
onNavigate?: (path: string) => void
20-
}> = (props) => {
19+
}
20+
21+
export const Pagination: React.FC<PaginationProps> = (props) => {
2122
const { className, page, totalPages, onNavigate } = props
2223
const hasNextPage = page < totalPages
2324
const hasPrevPage = page > 1
2425

2526
const hasExtraPrevPages = page - 1 > 1
2627
const hasExtraNextPages = page + 1 < totalPages
2728

28-
const router = useRouter()
29-
const navigate = onNavigate ?? router.push
29+
const navigate = onNavigate ?? (() => undefined)
3030

3131
const goToPage = (targetPage: number) => {
3232
if (targetPage < 1 || targetPage > totalPages) return
33-
navigate(`/posts/page/${targetPage}`)
33+
const targetPath = targetPage === 1 ? '/posts' : `/posts/page/${targetPage}`
34+
navigate(targetPath)
3435
}
3536

3637
return (
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
const mocks = vi.hoisted(() => ({
4+
getPayloadMock: vi.fn(),
5+
findMock: vi.fn(),
6+
countMock: vi.fn(),
7+
notFoundMock: vi.fn(() => {
8+
throw new Error('notFound')
9+
}),
10+
redirectMock: vi.fn((path: string) => {
11+
throw new Error(`redirect:${path}`)
12+
}),
13+
}))
14+
15+
vi.mock('next/navigation', () => ({
16+
notFound: mocks.notFoundMock,
17+
redirect: mocks.redirectMock,
18+
}))
19+
20+
vi.mock('@payload-config', () => ({
21+
default: {},
22+
}))
23+
24+
vi.mock('payload', async (importOriginal) => {
25+
const actual = await importOriginal<typeof import('payload')>()
26+
return {
27+
...actual,
28+
getPayload: mocks.getPayloadMock.mockResolvedValue({
29+
find: mocks.findMock,
30+
count: mocks.countMock,
31+
}),
32+
}
33+
})
34+
35+
describe('Paginated posts page route', () => {
36+
beforeEach(() => {
37+
vi.clearAllMocks()
38+
})
39+
40+
const getModule = async () => import('@/app/(frontend)/posts/page/[pageNumber]/page')
41+
42+
it('redirects page 1 to canonical /posts', async () => {
43+
const pageModule = await getModule()
44+
45+
await expect(pageModule.default({ params: Promise.resolve({ pageNumber: '1' }) })).rejects.toThrow(
46+
'redirect:/posts',
47+
)
48+
49+
expect(mocks.redirectMock).toHaveBeenCalledWith('/posts')
50+
expect(mocks.getPayloadMock).not.toHaveBeenCalled()
51+
expect(mocks.findMock).not.toHaveBeenCalled()
52+
})
53+
54+
it('returns notFound for non-numeric page params', async () => {
55+
const pageModule = await getModule()
56+
57+
await expect(pageModule.default({ params: Promise.resolve({ pageNumber: 'abc' }) })).rejects.toThrow('notFound')
58+
59+
expect(mocks.notFoundMock).toHaveBeenCalled()
60+
expect(mocks.getPayloadMock).not.toHaveBeenCalled()
61+
expect(mocks.findMock).not.toHaveBeenCalled()
62+
})
63+
64+
it('returns notFound for page 0 and skips payload initialization', async () => {
65+
const pageModule = await getModule()
66+
67+
await expect(pageModule.default({ params: Promise.resolve({ pageNumber: '0' }) })).rejects.toThrow('notFound')
68+
69+
expect(mocks.notFoundMock).toHaveBeenCalled()
70+
expect(mocks.getPayloadMock).not.toHaveBeenCalled()
71+
expect(mocks.findMock).not.toHaveBeenCalled()
72+
})
73+
74+
it('returns notFound for out-of-range pages', async () => {
75+
mocks.findMock.mockResolvedValueOnce({
76+
docs: [],
77+
totalPages: 2,
78+
totalDocs: 24,
79+
page: 3,
80+
})
81+
82+
const pageModule = await getModule()
83+
84+
await expect(pageModule.default({ params: Promise.resolve({ pageNumber: '3' }) })).rejects.toThrow('notFound')
85+
86+
expect(mocks.findMock).toHaveBeenCalled()
87+
expect(mocks.notFoundMock).toHaveBeenCalled()
88+
})
89+
90+
it('builds static params with page size 12 and skips page 1', async () => {
91+
mocks.countMock.mockResolvedValueOnce({ totalDocs: 25 })
92+
93+
const pageModule = await getModule()
94+
95+
await expect(pageModule.generateStaticParams()).resolves.toEqual([{ pageNumber: '2' }, { pageNumber: '3' }])
96+
})
97+
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// @vitest-environment jsdom
2+
import React from 'react'
3+
import '@testing-library/jest-dom'
4+
import { fireEvent, render, screen } from '@testing-library/react'
5+
import { describe, expect, it, vi } from 'vitest'
6+
7+
import { Pagination } from '@/components/molecules/Pagination'
8+
9+
describe('Pagination molecule', () => {
10+
it('navigates previous from page 2 to canonical /posts', () => {
11+
const onNavigate = vi.fn()
12+
13+
render(<Pagination page={2} totalPages={5} onNavigate={onNavigate} />)
14+
15+
fireEvent.click(screen.getByLabelText('Go to previous page'))
16+
17+
expect(onNavigate).toHaveBeenCalledWith('/posts')
18+
})
19+
20+
it('navigates page 1 button to canonical /posts', () => {
21+
const onNavigate = vi.fn()
22+
23+
render(<Pagination page={1} totalPages={3} onNavigate={onNavigate} />)
24+
25+
fireEvent.click(screen.getByRole('button', { name: '1' }))
26+
27+
expect(onNavigate).toHaveBeenCalledWith('/posts')
28+
})
29+
})

0 commit comments

Comments
 (0)