feat: articles pagination
This commit is contained in:
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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
)
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user