fix(SPA): save article list pagination in query url
This commit is contained in:
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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user