Files
RenewCMS/web/src/views/__tests__/ContentListPaging.spec.ts
T

290 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
SKELETON_DELAY_MS,
calls,
chooseSize,
eventEntries,
failWith,
installSelectPolyfills,
listPage,
listRequests,
mountList,
pending,
press,
text,
unmountAll,
} from './contentTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeAll(installSelectPolyfills)
afterEach(unmountAll)
const TOTAL = 37
function paged(_: string, query: URLSearchParams) {
const size = Number(query.get('size'))
const page = Number(query.get('page'))
const remaining = Math.max(0, Math.min(size, TOTAL - (page - 1) * size))
return listPage(
Array.from({ length: Math.min(remaining, 7) }, (__, index) => eventEntries[index]),
TOTAL,
query,
)
}
function main() {
return document.body.querySelector('#dashboard-content')!
}
function summary() {
return text(document.body.querySelector('[data-testid="paginator-summary"]'))
}
function pagerButtons() {
return [
...document.body.querySelectorAll<HTMLButtonElement>('nav[aria-label="Pagination"] button'),
]
}
function pagerLabels() {
return [...document.body.querySelectorAll('nav[aria-label="Pagination"] > *')].map(
(element) => element.getAttribute('aria-label') ?? text(element),
)
}
function lastRequest() {
return listRequests(calls).at(-1)
}
function alertText() {
return text(main().querySelector('[role="alert"], [role="status"]'))
}
describe('2 Paginated, 5 per page', () => {
it('shows the summary, the page size and the paginator', async () => {
await mountList('/content/events?size=5', { list: paged })
expect(summary()).toBe('Showing 1–5 of 37')
expect(text(document.body.querySelector('[aria-label="Rows per page"]'))).toBe('5 per page')
expect(pagerLabels()).toEqual([
'First page',
'Previous page',
'1',
'2',
'…',
'8',
'Next page',
'Last page',
])
expect(
pagerButtons()
.slice(0, 2)
.every((button) => button.disabled),
).toBe(true)
expect(
main()
.querySelector('nav[aria-label="Pagination"] [aria-current="page"]')
?.textContent?.trim(),
).toBe('1')
expect(main().querySelector('[data-testid="paginator-count"]')).toBeNull()
})
it('shows Previous, Page 1 of 8 and Next on mobile', async () => {
await mountList('/content/events?size=5', { list: paged, width: MOBILE_WIDTH })
const nav = text(main().querySelector('nav[aria-label="Pagination"]'))
expect(nav).toBe('Previous Page 1 of 8 Next')
expect(
main().querySelector<HTMLButtonElement>('[aria-label="Previous page"]')?.disabled,
).toBe(true)
})
it('sends page 2 on Next and updates the URL on success', async () => {
await mountList('/content/events?size=5', { list: paged })
await press('Next page')
expect(lastRequest()).toBe(
'/v1/content-type/events/entry?page=2&size=5&sort=updated_at:desc',
)
expect(summary()).toBe('Showing 6–10 of 37')
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&page=2')
})
it('goes to the last page with Last', async () => {
await mountList('/content/events?size=5', { list: paged })
await press('Last page')
expect(summary()).toBe('Showing 36–37 of 37')
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&page=8')
expect(
pagerButtons()
.filter((button) =>
['Next page', 'Last page'].includes(button.getAttribute('aria-label')!),
)
.every((button) => button.disabled),
).toBe(true)
})
})
describe('Page size select', () => {
it.each([
['5 per page', 5, '/content/events?size=5'],
['25 per page', 25, '/content/events'],
['50 per page', 50, '/content/events?size=50'],
['100 per page', 100, '/content/events?size=100'],
])('choosing "%s" resets to page 1 and ends on %s', async (label, size, url) => {
await mountList('/content/events?size=5&page=3', { list: paged })
await chooseSize(label)
expect(lastRequest()).toBe(
`/v1/content-type/events/entry?page=1&size=${size}&sort=updated_at:desc`,
)
expect(router.currentRoute.value.fullPath).toBe(url)
})
it('does not remember the page size', async () => {
await mountList('/content/events', { list: paged })
await chooseSize('50 per page')
await unmountAll()
await mountList('/content/events', { list: paged })
expect(lastRequest()).toBe(
'/v1/content-type/events/entry?page=1&size=25&sort=updated_at:desc',
)
expect(Object.keys(localStorage).filter((key) => key.includes('page-size'))).toEqual([])
})
})
describe('Loading a page', () => {
it('shows Loading page 2…, disables the controls and marks the list busy', async () => {
vi.useFakeTimers()
const gate = pending()
await mountList('/content/events?size=5', {
list: (key, query) => (query.get('page') === '2' ? gate.promise : paged(key, query)),
})
document.body.querySelector<HTMLElement>('[aria-label="Next page"]')!.click()
vi.advanceTimersByTime(SKELETON_DELAY_MS)
await flushPromises()
expect(summary()).toBe('Loading page 2…')
expect(pagerButtons().every((button) => button.disabled)).toBe(true)
expect(
document.body.querySelector<HTMLButtonElement>('[aria-label="Rows per page"]')
?.disabled,
).toBe(true)
expect(main().querySelector('[aria-busy="true"]')).not.toBeNull()
expect(main().querySelectorAll('[data-testid="entry-skeleton-row"]')).toHaveLength(5)
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5')
})
})
describe('Error loading a page', () => {
it('keeps the rows, shows the notice with Retry and leaves the URL', async () => {
await mountList('/content/events?size=5', {
list: (key, query) => {
if (query.get('page') === '2') {
throw new Error('x')
}
return paged(key, query)
},
})
await press('Next page')
expect(alertText()).toContain("Couldn't load page 2. Showing page 1.")
expect(main().querySelectorAll('tbody tr')).toHaveLength(5)
expect(summary()).toBe('Showing 1–5 of 37')
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5')
expect(main().querySelector('h1')?.textContent).toBe('Events')
})
it('Retry sends the failed page again and updates the URL on success', async () => {
let attempt = 0
await mountList('/content/events?size=5', {
list: (key, query) => {
if (query.get('page') === '2' && attempt++ === 0) {
throw new Error('x')
}
return paged(key, query)
},
})
await press('Next page')
const retry = [...main().querySelectorAll('button')].find(
(candidate) => text(candidate) === 'Retry',
)!
retry.click()
await flushPromises()
expect(summary()).toBe('Showing 6–10 of 37')
expect(alertText()).toBe('')
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&page=2')
})
it('shows the error state, not a notice, when the first load fails', async () => {
await mountList('/content/events?page=2', { list: failWith(500) })
expect(text(main())).toContain("Couldn't load entries")
expect(text(main())).not.toContain("Couldn't load page")
})
})
describe('Out-of-range page', () => {
it('loads the last page, shows the notice and replaces the URL', async () => {
await mountList('/content/events?size=5&page=99', {
list: (key, query) =>
Number(query.get('page')) > 8 ? listPage([], TOTAL, query) : paged(key, query),
})
expect(lastRequest()).toBe(
'/v1/content-type/events/entry?page=8&size=5&sort=updated_at:desc',
)
expect(alertText()).toContain("That page doesn't exist. Showing the last page.")
expect(summary()).toBe('Showing 36–37 of 37')
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&page=8')
})
it('shows the empty state and cleans the URL when the total is 0', async () => {
await mountList('/content/events?page=3', { list: (_, query) => listPage([], 0, query) })
expect(text(main())).toContain('No entries yet')
expect(main().querySelector('[data-testid="paginator-summary"]')).toBeNull()
expect(router.currentRoute.value.fullPath).toBe('/content/events')
})
})
describe('Focus-visible', () => {
it('gives the paginator, the select and the toolbar controls a 2 px primary outline with a 2 px offset', async () => {
await mountList('/content/events?size=5&page=3', { list: paged })
const controls = [
...pagerButtons(),
document.body.querySelector('[aria-label="Rows per page"]'),
document.body.querySelector('[aria-label="Sort by"]'),
document.body.querySelector('[aria-label="Sort ascending"]'),
[...document.body.querySelectorAll('button')].find(
(candidate) => text(candidate) === 'Columns',
),
]
expect(controls.length).toBeGreaterThan(8)
for (const element of controls) {
expect(element?.className).toContain('focus-visible:outline-2')
expect(element?.className).toContain('focus-visible:outline-offset-2')
expect(element?.className).toContain('focus-visible:outline-primary')
}
})
})