feat: entry list at /content/:key
This commit is contained in:
1 parent
b4cbcb0395
commit
91d916fb74
44 files changed
+4984
-32
No files matched your search
@@ -0,0 +1,289 @@
|
||||
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')
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user