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,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
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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