feat: articles pagination

This commit is contained in:
Florian Sylvain committed 2026-10-11 19:14:37 +02:00
1 parent a2d224240c
commit 73f4b539fe
45 files changed
+3528 -317

No files matched your search

@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import { nextSort, parseArticleQuery, sameArticleQuery, toRouteQuery } from '@/lib/articleListQuery'
describe('parseArticleQuery', () => {
it('falls back to page 1, size 25, no search and the All tab', () => {
expect(parseArticleQuery({})).toEqual({ page: 1, size: 25, q: '', status: '', sort: '' })
})
it('reads valid values', () => {
expect(
parseArticleQuery({
page: '7',
size: '50',
q: 'plaster',
status: 'draft',
sort: 'title:asc',
}),
).toEqual({ page: 7, size: 50, q: 'plaster', status: 'draft', sort: 'title:asc' })
})
it.each(['abc', '0', '-2', '2.5', '', '1e2'])('treats page "%s" as 1', (page) => {
expect(parseArticleQuery({ page }).page).toBe(1)
})
it.each(['10', 'abc', '0', '26', ''])('treats size "%s" as 25', (size) => {
expect(parseArticleQuery({ size }).size).toBe(25)
})
it('ignores an unknown status', () => {
expect(parseArticleQuery({ status: 'archived' }).status).toBe('')
})
it('reads the first value of a repeated parameter and caps the search at 200 characters', () => {
expect(parseArticleQuery({ page: ['3', '4'] }).page).toBe(3)
expect(parseArticleQuery({ q: 'a'.repeat(250) }).q).toHaveLength(200)
})
})
describe('sort parameter', () => {
it.each(['title:asc', 'status:desc', 'created_at:asc', 'created_at:desc'])(
'keeps %s',
(sort) => {
expect(parseArticleQuery({ sort }).sort).toBe(sort)
},
)
it.each(['body:asc', 'title', 'title:up', 'TITLE:ASC', ''])('drops invalid "%s"', (sort) => {
expect(parseArticleQuery({ sort }).sort).toBe('')
})
it('treats the default as no sort', () => {
expect(parseArticleQuery({ sort: 'updated_at:desc' }).sort).toBe('')
})
it('omits the default from the route and treats it as equal to no sort', () => {
const base = { page: 1, size: 25, q: '', status: '' as const }
expect(toRouteQuery({ ...base, sort: 'updated_at:desc' })).toEqual({})
expect(toRouteQuery({ ...base, sort: 'title:asc' })).toEqual({ sort: 'title:asc' })
expect(sameArticleQuery({ ...base, sort: '' }, { ...base, sort: 'updated_at:desc' })).toBe(
true,
)
expect(sameArticleQuery({ ...base, sort: 'title:asc' }, { ...base, sort: '' })).toBe(false)
})
it('toggles: first click ascending, second descending, other column restarts', () => {
expect(nextSort('', 'title')).toBe('title:asc')
expect(nextSort('title:asc', 'title')).toBe('title:desc')
expect(nextSort('title:desc', 'title')).toBe('title:asc')
expect(nextSort('title:asc', 'status')).toBe('status:asc')
expect(nextSort('', 'updated_at')).toBe('updated_at:asc')
expect(nextSort('updated_at:asc', 'updated_at')).toBe('updated_at:desc')
})
})
describe('toRouteQuery', () => {
it('omits every default', () => {
expect(toRouteQuery({ page: 1, size: 25, q: '', status: '', sort: '' })).toEqual({})
})
it('keeps what differs from the defaults', () => {
expect(
toRouteQuery({ page: 2, size: 5, q: 'plaster', status: 'online', sort: 'title:desc' }),
).toEqual({
page: '2',
size: '5',
q: 'plaster',
status: 'online',
sort: 'title:desc',
})
})
})
describe('sameArticleQuery', () => {
it('compares every field', () => {
const base = { page: 1, size: 25, q: '', status: '' as const, sort: '' }
expect(sameArticleQuery(base, { ...base })).toBe(true)
expect(sameArticleQuery(base, { ...base, page: 2 })).toBe(false)
expect(sameArticleQuery(base, { ...base, size: 5 })).toBe(false)
expect(sameArticleQuery(base, { ...base, q: 'a' })).toBe(false)
expect(sameArticleQuery(base, { ...base, status: 'draft' })).toBe(false)
})
})
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { pageItems, pageCountFor, rangeFor } from '@/lib/pagination'
describe('pageItems', () => {
it('lists every page when there are seven or fewer', () => {
expect(pageItems(3, 7)).toEqual([1, 2, 3, 4, 5, 6, 7])
expect(pageItems(1, 1)).toEqual([1])
})
it('marks the skipped range after the first page', () => {
expect(pageItems(1, 13)).toEqual([1, 2, 'gap', 13])
})
it('marks skipped ranges on both sides of a middle page', () => {
expect(pageItems(7, 13)).toEqual([1, 'gap', 6, 7, 8, 'gap', 13])
})
it('marks the skipped range before the last page', () => {
expect(pageItems(13, 13)).toEqual([1, 'gap', 12, 13])
})
it('does not mark a gap between adjacent pages', () => {
expect(pageItems(3, 13)).toEqual([1, 2, 3, 4, 'gap', 13])
})
})
describe('pageCountFor', () => {
it('rounds up and never drops below one', () => {
expect(pageCountFor(312, 25)).toBe(13)
expect(pageCountFor(6, 25)).toBe(1)
expect(pageCountFor(0, 25)).toBe(1)
})
})
describe('rangeFor', () => {
it('computes the shown range', () => {
expect(rangeFor(1, 25, 312)).toEqual({ from: 1, to: 25 })
expect(rangeFor(7, 25, 312)).toEqual({ from: 151, to: 175 })
expect(rangeFor(13, 25, 312)).toEqual({ from: 301, to: 312 })
expect(rangeFor(1, 25, 0)).toEqual({ from: 0, to: 0 })
})
})