fix(SPA): save article list pagination in query url

This commit is contained in:
Florian Sylvain committed 2026-10-13 16:20:37 +02:00
1 parent 54f27ac98f
commit 9d9453c1b3
7 files changed
+535 -2

No files matched your search

@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readStoredPageSize, storePageSize } from '../pageSizeMemory'
const KEY = 'renewcms-page-size-articles'
const SIZES = [5, 25, 50, 100]
describe('pageSizeMemory', () => {
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('reads null when nothing is stored', () => {
expect(readStoredPageSize(KEY, SIZES)).toBeNull()
})
it.each(['5', '25', '50', '100'])('reads the valid stored size %s', (raw) => {
localStorage.setItem(KEY, raw)
expect(readStoredPageSize(KEY, SIZES)).toBe(Number(raw))
})
it.each(['10', 'abc', '0', '-5', '25.5', '', '5.0', ' 5'])('treats %j as absent', (raw) => {
localStorage.setItem(KEY, raw)
expect(readStoredPageSize(KEY, SIZES)).toBeNull()
})
it('stores the bare number', () => {
storePageSize(KEY, 50)
expect(localStorage.getItem(KEY)).toBe('50')
})
it('reads as absent when getItem throws', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('denied')
})
expect(readStoredPageSize(KEY, SIZES)).toBeNull()
})
it('swallows a setItem that throws', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota')
})
expect(() => storePageSize(KEY, 50)).not.toThrow()
})
it('copes with localStorage being undefined', () => {
vi.stubGlobal('localStorage', undefined)
expect(readStoredPageSize(KEY, SIZES)).toBeNull()
expect(() => storePageSize(KEY, 50)).not.toThrow()
})
})
+18
View File
@@ -0,0 +1,18 @@
export const ARTICLES_PAGE_SIZE_KEY = 'renewcms-page-size-articles'
export function readStoredPageSize(key: string, validSizes: number[]) {
try {
const raw = localStorage.getItem(key)
return validSizes.find((size) => String(size) === raw) ?? null
} catch {
return null
}
}
export function storePageSize(key: string, size: number) {
try {
localStorage.setItem(key, String(size))
} catch {
return
}
}
+18 -2
View File
@@ -20,6 +20,7 @@ import {
} from '@/composables/useArticles'
import { bodyImageCount } from '@/lib/bodyImageIds'
import {
ARTICLE_PAGE_SIZES,
effectiveSort,
nextSort,
parseArticleQuery,
@@ -30,6 +31,7 @@ import {
type ArticleListQuery,
} from '@/lib/articleListQuery'
import { pageCountFor } from '@/lib/pagination'
import { ARTICLES_PAGE_SIZE_KEY, readStoredPageSize, storePageSize } from '@/lib/pageSizeMemory'
const SKELETON_DELAY_MS = 150
const SEARCH_DEBOUNCE_MS = 120
@@ -49,7 +51,16 @@ const toast = useLocalizedToast()
const items = ref<Article[]>([])
const total = ref(0)
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
const shown = ref<ArticleListQuery>(parseArticleQuery(route.query))
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
const parsed = parseArticleQuery(query)
if (query.size !== undefined) {
return parsed
}
const stored = readStoredPageSize(ARTICLES_PAGE_SIZE_KEY, ARTICLE_PAGE_SIZES)
return stored === null ? parsed : { ...parsed, size: stored }
}
const shown = ref<ArticleListQuery>(parseWithStoredSize(route.query))
const desired = ref<ArticleListQuery>(shown.value)
const hasLoaded = ref(false)
const loading = ref(true)
@@ -62,6 +73,7 @@ const newArticleModalOpen = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
let requestToken = 0
let syncedTarget: Record<string, string> | null = null
const searchInput = ref(shown.value.q)
@@ -152,6 +164,7 @@ async function syncUrl(query: ArticleListQuery, mode: UrlMode) {
if (sameRouteQuery(route.query, target)) {
return
}
syncedTarget = target
await router[mode]({ query: target })
}
@@ -216,6 +229,7 @@ function changePage(page: number) {
}
function changeSize(size: number) {
storePageSize(ARTICLES_PAGE_SIZE_KEY, size)
load({ ...desired.value, size, page: 1 }, 'push')
}
@@ -250,7 +264,9 @@ watch(
if (route.path !== ARTICLES_PATH) {
return
}
const next = parseArticleQuery(route.query)
const ownReplace = syncedTarget !== null && sameRouteQuery(route.query, syncedTarget)
syncedTarget = null
const next = ownReplace ? parseArticleQuery(route.query) : parseWithStoredSize(route.query)
if (hasLoaded.value && sameArticleQuery(next, shown.value)) {
syncUrl(shown.value, 'replace')
return
@@ -0,0 +1,295 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ArticleView from '../ArticleView.vue'
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
const KEY = 'renewcms-page-size-articles'
const TOTAL = 312
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return {
...actual,
useOverlay: () => ({ create: vi.fn() }),
useToast: () => ({ add: vi.fn() }),
}
})
const articles: Article[] = Array.from({ length: TOTAL }, (_, index) => ({
id: index + 1,
title: `Article ${index + 1}`,
body: '',
is_online: (index + 1) % 3 !== 0,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-08-18T09:00:00Z',
}))
let listCalls: EnvelopeParams[]
let wrapper: Awaited<ReturnType<typeof mount>> | null = null
function installFetch() {
global.fetch = vi.fn((url: string, init?: RequestInit) => {
const method = init?.method ?? 'GET'
if (method === 'GET' && url.startsWith('/v1/article/?')) {
const params = parseListUrl(url)
listCalls.push(params)
return Promise.resolve({
ok: true,
status: 200,
headers: { get: () => null },
json: async () => articleEnvelope(articles, params),
} as unknown as Response)
}
throw new Error(`Unexpected fetch call: ${method} ${url}`)
}) as unknown as typeof fetch
}
async function mountAt(path: string) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
await router.push(path)
wrapper = mount(ArticleView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
}
function unmount() {
wrapper?.unmount()
wrapper = null
}
function selectText() {
return document.body.querySelector('[aria-label="Rows per page"]')?.textContent?.trim()
}
async function choose(label: string) {
document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')?.click()
await flushPromises()
const option = Array.from(document.body.querySelectorAll('[role="option"]')).find(
(element) => element.textContent?.trim() === label,
)
;(option as HTMLElement).click()
await flushPromises()
}
async function click(label: string) {
document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)?.click()
await flushPromises()
}
function lastCall() {
return listCalls.at(-1)
}
function fullPath() {
return router.currentRoute.value.fullPath
}
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
})
beforeEach(() => {
localStorage.clear()
listCalls = []
setViewportWidth(DESKTOP_WIDTH)
installFetch()
})
afterEach(async () => {
unmount()
vi.restoreAllMocks()
vi.unstubAllGlobals()
await router.push('/login')
})
describe('Choosing a size stores it', () => {
it.each([
['5 per page', '5', '/article?size=5'],
['25 per page', '25', '/article'],
['50 per page', '50', '/article?size=50'],
['100 per page', '100', '/article?size=100'],
])('%s', async (label, stored, url) => {
await mountAt('/article')
await choose(label)
expect(localStorage.getItem(KEY)).toBe(stored)
expect(fullPath()).toBe(url)
})
})
describe('Restoring the stored size', () => {
it('The stored size is restored when the list is opened without a size', async () => {
localStorage.setItem(KEY, '5')
await mountAt('/article')
expect(listCalls).toEqual([{ page: 1, size: 5, q: '', status: '', sort: '' }])
expect(selectText()).toBe('5 per page')
expect(fullPath()).toBe('/article?size=5')
})
it('The size survives leaving the list and coming back', async () => {
await mountAt('/article')
await choose('5 per page')
unmount()
await router.push('/')
listCalls = []
await mountAt('/article')
expect(lastCall()).toMatchObject({ size: 5 })
expect(selectText()).toBe('5 per page')
})
it('The size survives a full page refresh', async () => {
await mountAt('/article')
await choose('5 per page')
unmount()
listCalls = []
await mountAt('/article?size=5')
expect(lastCall()).toMatchObject({ size: 5 })
expect(selectText()).toBe('5 per page')
unmount()
listCalls = []
await mountAt('/article')
expect(lastCall()).toMatchObject({ size: 5 })
expect(selectText()).toBe('5 per page')
})
it('The stored size is kept across paging, search, tabs and sort', async () => {
localStorage.setItem(KEY, '50')
await mountAt('/article?size=50')
await click('Next page')
await router.push('/article?size=50&status=draft')
await flushPromises()
await router.push('/article?size=50&sort=title:asc')
await flushPromises()
await router.push('/article?size=50&q=plaster')
await flushPromises()
expect(listCalls.length).toBeGreaterThan(1)
expect(listCalls.every((call) => call.size === 50)).toBe(true)
expect(localStorage.getItem(KEY)).toBe('50')
})
it('An explicit page with no size uses the stored size', async () => {
localStorage.setItem(KEY, '5')
await mountAt('/article?page=3')
expect(lastCall()).toMatchObject({ page: 3, size: 5 })
expect(fullPath()).toBe('/article?page=3&size=5')
})
it('Logging out keeps the stored size', async () => {
localStorage.setItem(KEY, '5')
await mountAt('/article')
unmount()
await router.push('/login')
listCalls = []
await mountAt('/article')
expect(lastCall()).toMatchObject({ size: 5 })
})
})
describe('Replacing the stored size', () => {
it('Changing the size again replaces the stored one', async () => {
localStorage.setItem(KEY, '5')
await mountAt('/article')
await choose('100 per page')
expect(localStorage.getItem(KEY)).toBe('100')
unmount()
listCalls = []
await mountAt('/article')
expect(lastCall()).toMatchObject({ size: 100 })
})
it('Returning to 25 is remembered too', async () => {
localStorage.setItem(KEY, '5')
await mountAt('/article')
await choose('25 per page')
expect(localStorage.getItem(KEY)).toBe('25')
unmount()
listCalls = []
await mountAt('/article')
expect(lastCall()).toMatchObject({ size: 25 })
expect(fullPath()).toBe('/article')
})
})
describe('URL precedence', () => {
it('An explicit size in the URL wins and is not stored', async () => {
localStorage.setItem(KEY, '5')
await mountAt('/article?size=100')
expect(lastCall()).toMatchObject({ page: 1, size: 100 })
expect(selectText()).toBe('100 per page')
expect(localStorage.getItem(KEY)).toBe('5')
unmount()
listCalls = []
await mountAt('/article')
expect(lastCall()).toMatchObject({ size: 5 })
})
it('An invalid size in the URL ignores the stored one', async () => {
localStorage.setItem(KEY, '5')
await mountAt('/article?size=10')
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(fullPath()).toBe('/article')
expect(localStorage.getItem(KEY)).toBe('5')
})
})
describe('An invalid stored value falls back to 25', () => {
it.each(['10', 'abc', '0', '-5', '25.5', ''])('stored %j', async (stored) => {
localStorage.setItem(KEY, stored)
await mountAt('/article')
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(selectText()).toBe('25 per page')
expect(fullPath()).toBe('/article')
})
})
describe('Unavailable storage never breaks the list', () => {
async function expectWorking() {
await mountAt('/article')
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(document.body.querySelector('[role="alert"]')).toBeNull()
await choose('50 per page')
expect(lastCall()).toMatchObject({ page: 1, size: 50 })
expect(fullPath()).toBe('/article?size=50')
}
it('throws on getItem', async () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('denied')
})
await expectWorking()
})
it('throws on setItem (quota)', async () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota')
})
await expectWorking()
})
it('is undefined', async () => {
vi.stubGlobal('localStorage', undefined)
await expectWorking()
})
})
@@ -155,6 +155,7 @@ beforeAll(() => {
})
beforeEach(() => {
localStorage.clear()
articles = buildArticles(TOTAL)
listCalls = []
listHandler = (params) => respondFromFixture(params)