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

+115
View File
@@ -0,0 +1,115 @@
Feature: Articles list remembers the chosen page size
As a verified RenewCMS user
I want the page size I choose on the Articles list to be remembered
So that I do not have to pick it again each time I come back or refresh
Background:
Given this slice amends features/articles_pagination_screen.feature; the URL keeps ?size= as in that spec and no visual or API change is made (docs/design/ is untouched)
And the size options stay "5 per page", "25 per page", "50 per page", "100 per page" and the default stays 25
And the chosen size is stored in localStorage under the key renewcms-page-size-articles as the bare number (for example 5), one key per paginated list; only the Articles list uses it today
And only choosing a size in the page-size select writes the stored value; loading a URL never writes it
And an explicit valid ?size= in the URL is authoritative for that load and does not change the stored value
And an invalid ?size= in the URL falls back to 25, never to the stored value, and is replaced in the URL (features/articles_pagination_screen.feature scenario 10b)
And a stored value that is not 5, 25, 50 or 100 is treated as absent
And localStorage that is unavailable or throws on read or write never raises an error or breaks the page: reads act as absent and writes are ignored
And logout does not clear the stored size
And a size change still resets page to 1 and drops page from the URL
And the requests are GET /v1/article/?format=html&page=P&size=S&q=Q&status=T as in features/articles_pagination_api.feature
Scenario Outline: Choosing a size stores it
Given nothing is stored and the user is on /article
When the user chooses "<label>" in the page-size select
Then localStorage renewcms-page-size-articles is "<stored>"
And on success the URL is <url>
Examples:
| label | stored | url |
| 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 |
Scenario: The stored size is restored when the list is opened without a size
Given localStorage renewcms-page-size-articles is "5"
When the user navigates to /article
Then GET ...&page=1&size=5 is sent
And the page-size select reads "5 per page"
And on success the URL is replaced (no history entry) by /article?size=5
Scenario: The size survives leaving the list and coming back
Given the user chose "5 per page" on /article
When the user navigates to /, then back to /article
Then GET ...&size=5 is sent and the select reads "5 per page"
Scenario: The size survives a full page refresh
Given the user chose "5 per page" on /article
When the page is reloaded on /article?size=5, or on /article with the URL typed in without a size
Then GET ...&size=5 is sent and the select reads "5 per page"
Scenario: The stored size is kept across paging, search, tabs and sort
Given localStorage renewcms-page-size-articles is "50" and the user is on /article?size=50
When the user activates Next, switches to the "Drafts" tab, sorts by Title or searches "plaster"
Then every request carries size=50 and the stored value stays "50"
Scenario: Changing the size again replaces the stored one
Given localStorage renewcms-page-size-articles is "5"
When the user chooses "100 per page"
Then localStorage renewcms-page-size-articles is "100"
And the next visit to /article without a size sends size=100
Scenario: Returning to 25 is remembered too
Given localStorage renewcms-page-size-articles is "5"
When the user chooses "25 per page"
Then localStorage renewcms-page-size-articles is "25"
And the next visit to /article sends size=25 and the URL stays /article
Scenario: An explicit size in the URL wins and is not stored
Given localStorage renewcms-page-size-articles is "5"
When the user navigates to /article?size=100
Then GET ...&size=100 is sent and the select reads "100 per page"
And localStorage renewcms-page-size-articles is still "5"
And a later visit to /article sends size=5
Scenario: An explicit page with no size uses the stored size
Given localStorage renewcms-page-size-articles is "5"
When the user navigates to /article?page=3
Then GET ...&page=3&size=5 is sent and on success the URL is replaced by /article?page=3&size=5
Scenario Outline: An invalid stored value falls back to 25
Given localStorage renewcms-page-size-articles is "<stored>"
When the user navigates to /article
Then GET ...&page=1&size=25 is sent, the select reads "25 per page" and the URL is /article
Examples:
| stored |
| 10 |
| abc |
| 0 |
| -5 |
| 25.5 |
| |
Scenario: An invalid size in the URL ignores the stored one
Given localStorage renewcms-page-size-articles is "5"
When the user navigates to /article?size=10
Then GET ... is sent with size=25 and the URL is replaced by /article
And localStorage renewcms-page-size-articles is still "5"
Scenario Outline: Unavailable storage never breaks the list
Given localStorage <failure>
When the user navigates to /article
Then GET ...&page=1&size=25 is sent, the list renders and no error is shown
When the user chooses "50 per page"
Then GET ...&page=1&size=50 is sent and the URL is /article?size=50
Examples:
| failure |
| throws on getItem |
| throws on setItem (quota) |
| is undefined |
Scenario: Logging out keeps the stored size
Given localStorage renewcms-page-size-articles is "5"
When the user logs out and logs in again and opens /article
Then GET ...&size=5 is sent
+33
View File
@@ -0,0 +1,33 @@
# QA: Articles page-size memory (SPA)
No Go or HTTP test applies: nothing outside web/ changes.
## Automated (Vitest, task test:web)
Extend or sit beside web/src/views/__tests__/ArticlesPagination.spec.ts and reuse articleEnvelope.ts for the mocked GET /v1/article/ envelope (total 312, size from the query). localStorage.clear() in beforeEach; fresh router per test. Assert on the mocked request query string, the select label, the router URL and localStorage.
1. Choosing 5 / 25 / 50 / 100 writes "5" / "25" / "50" / "100" under renewcms-page-size-articles; URLs /article?size=5, /article, ?size=50, ?size=100.
2. Stored "5", open /article: request carries size=5 and page=1, select reads "5 per page", URL replaced (router.replace, no history entry added) with ?size=5.
3. Choose 5, navigate to another route and back (mount again): size=5 requested.
4. Choose 5, remount the app on /article and on /article?size=5 (simulated refresh): size=5 requested.
5. Stored "50", on ?size=50: Next, Drafts tab, sort Title, search "plaster" (fake timers for the 120 ms debounce, not the system date): every request has size=50, stored value unchanged.
6. Stored "5", choose 100: stored "100"; remount sends size=100.
7. Stored "5", choose 25: stored "25"; remount sends size=25, URL /article.
8. Stored "5", open /article?size=100: size=100 requested, stored still "5"; remount on /article sends size=5.
9. Stored "5", open /article?page=3: request page=3&size=5, URL replaced by ?page=3&size=5.
10. Invalid stored values 10, abc, 0, -5, 25.5, "": size=25 requested, select "25 per page", URL /article.
11. Stored "5", open /article?size=10: size=25 requested, URL /article, stored still "5".
12. Storage failures (Storage.prototype.getItem throwing; setItem throwing; window.localStorage undefined): no exception, list renders, size=25 on load, choosing 50 requests size=50 and URL ?size=50.
13. Logout (session store reset) leaves the stored value; the next /article sends size=5.
14. Unit tests for any read/write helper added next to web/src/lib/pagination.ts: valid values accepted, invalid rejected, errors swallowed.
## Visual (task visual:check -- articles)
No visual change is intended. Run the full articles group as a regression and require every capture within 10 %. Setups use the board's own sample data and must not depend on a stored size. Clear localStorage before each capture so a leftover value cannot change the default 25 frame. No state is clock-sensitive for this slice.
## Manual
- In a real browser, choose 5 per page, navigate away and back, then hard-refresh: it stays at 5.
- Private-window or storage-blocked browser: the list still works at 25 with no error.
- Keyboard: the page-size select behaves as before (ArrowDown, Enter, Esc, focus ring), and focus returns to the select after choosing.
- Open a copied /article?size=100 link in a profile that has 5 stored: the link shows 100 and the next plain visit shows 5.
@@ -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)