feat: entry list at /content/:key

This commit is contained in:
Florian Sylvain committed 2026-10-20 21:05:03 +02:00
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')
}
})
})