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 })
})
})
+91
View File
@@ -0,0 +1,91 @@
import type { LocationQuery } from 'vue-router'
export type ArticleStatus = 'online' | 'draft'
export const ARTICLE_PAGE_SIZES = [5, 25, 50, 100]
export const DEFAULT_ARTICLE_PAGE_SIZE = 25
export const ARTICLE_SEARCH_MAX_LENGTH = 200
export const DEFAULT_ARTICLE_SORT = 'updated_at:desc'
export const ARTICLE_SORT_FIELDS = ['title', 'status', 'created_at', 'updated_at'] as const
export type ArticleSortField = (typeof ARTICLE_SORT_FIELDS)[number]
const POSITIVE_INTEGER = /^[1-9]\d*$/
export interface ArticleListQuery {
page: number
size: number
q: string
status: ArticleStatus | ''
sort: string
}
function first(value: LocationQuery[string] | undefined) {
const single = Array.isArray(value) ? value[0] : value
return single ?? ''
}
const SORT_PATTERN = new RegExp(`^(${ARTICLE_SORT_FIELDS.join('|')}):(asc|desc)$`)
function validSort(value: string) {
return SORT_PATTERN.test(value) && value !== DEFAULT_ARTICLE_SORT ? value : ''
}
export function effectiveSort(sort: string) {
return sort || DEFAULT_ARTICLE_SORT
}
export function nextSort(current: string, field: ArticleSortField) {
const [activeField, direction] = effectiveSort(current).split(':')
return `${field}:${activeField === field && direction === 'asc' ? 'desc' : 'asc'}`
}
export function parseArticleQuery(query: LocationQuery): ArticleListQuery {
const page = first(query.page)
const size = Number(first(query.size))
const status = first(query.status)
return {
page: POSITIVE_INTEGER.test(page) ? Number(page) : 1,
size: ARTICLE_PAGE_SIZES.includes(size) ? size : DEFAULT_ARTICLE_PAGE_SIZE,
q: first(query.q).slice(0, ARTICLE_SEARCH_MAX_LENGTH),
status: status === 'online' || status === 'draft' ? status : '',
sort: validSort(first(query.sort)),
}
}
export function toRouteQuery(query: ArticleListQuery) {
const route: Record<string, string> = {}
if (query.page !== 1) {
route.page = String(query.page)
}
if (query.size !== DEFAULT_ARTICLE_PAGE_SIZE) {
route.size = String(query.size)
}
if (query.q) {
route.q = query.q
}
if (query.status) {
route.status = query.status
}
if (effectiveSort(query.sort) !== DEFAULT_ARTICLE_SORT) {
route.sort = query.sort
}
return route
}
export function sameArticleQuery(left: ArticleListQuery, right: ArticleListQuery) {
return (
left.page === right.page &&
left.size === right.size &&
left.q === right.q &&
left.status === right.status &&
effectiveSort(left.sort) === effectiveSort(right.sort)
)
}
export function sameRouteQuery(left: LocationQuery, right: Record<string, string>) {
const leftKeys = Object.keys(left)
return (
leftKeys.length === Object.keys(right).length &&
leftKeys.every((key) => first(left[key]) === right[key])
)
}
+30
View File
@@ -0,0 +1,30 @@
const FULL_LIST_LIMIT = 7
export type PageItem = number | 'gap'
export function pageCountFor(total: number, size: number) {
return Math.max(1, Math.ceil(total / size))
}
export function rangeFor(page: number, size: number, total: number) {
if (total === 0) {
return { from: 0, to: 0 }
}
return { from: (page - 1) * size + 1, to: Math.min(page * size, total) }
}
export function pageItems(page: number, pageCount: number): PageItem[] {
const visible =
pageCount <= FULL_LIST_LIMIT
? Array.from({ length: pageCount }, (_, index) => index + 1)
: [
...new Set(
[1, page - 1, page, page + 1, pageCount].filter(
(candidate) => candidate >= 1 && candidate <= pageCount,
),
),
].sort((left, right) => left - right)
return visible.flatMap((number, index) =>
index > 0 && number - visible[index - 1]! > 1 ? ['gap' as const, number] : [number],
)
}