290 lines
8.9 KiB
TypeScript
290 lines
8.9 KiB
TypeScript
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')
|
||
}
|
||
})
|
||
})
|