feat: pagination in the SPA for the Sites and Authors lists
This commit is contained in:
1 parent
500ad59458
commit
340a0ad461
34 files changed
+2833
-896
No files matched your search
@@ -0,0 +1,771 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { MissingTotalError } from '@/composables/useApi'
|
||||
import { setLocale } from '@/i18n'
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
authorsFixture,
|
||||
blur,
|
||||
callsTo,
|
||||
click,
|
||||
createBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
field,
|
||||
labelled,
|
||||
mountDirectory,
|
||||
networkFailure,
|
||||
paged,
|
||||
pending,
|
||||
rows,
|
||||
sitesFixture,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountDirectory,
|
||||
type Backend,
|
||||
} from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 120
|
||||
const SKELETON_DELAY_MS = 150
|
||||
|
||||
interface Screen {
|
||||
name: string
|
||||
view: 'sites' | 'authors'
|
||||
route: string
|
||||
path: string
|
||||
noun: string
|
||||
nounOne: string
|
||||
total: number
|
||||
pages: number
|
||||
middle: number
|
||||
middleSummary: string
|
||||
lastRows: number
|
||||
lastSummary: string
|
||||
searchTerm: string
|
||||
searchPlaceholder: string
|
||||
emptyTitle: string
|
||||
emptyTerm: string
|
||||
searchFrom: number
|
||||
searchTotal: number
|
||||
longPage: number
|
||||
longTotal: number
|
||||
longSummary: string
|
||||
longLabel: string
|
||||
}
|
||||
|
||||
const SCREENS: Screen[] = [
|
||||
{
|
||||
name: 'Sites',
|
||||
view: 'sites',
|
||||
route: '/settings/sites',
|
||||
path: '/v1/site/',
|
||||
noun: 'sites',
|
||||
nounOne: 'site',
|
||||
total: 61,
|
||||
pages: 3,
|
||||
middle: 2,
|
||||
middleSummary: 'Showing 26–50 of 61',
|
||||
lastRows: 11,
|
||||
lastSummary: 'Showing 51–61 of 61',
|
||||
searchTerm: 'renew',
|
||||
searchPlaceholder: 'Search by name or key',
|
||||
emptyTitle: 'No sites match "blog"',
|
||||
emptyTerm: 'blog',
|
||||
searchFrom: 4,
|
||||
searchTotal: 27,
|
||||
longPage: 6,
|
||||
longTotal: 312,
|
||||
longSummary: 'Showing 126–150 of 312',
|
||||
longLabel: 'Page 6 of 13',
|
||||
},
|
||||
{
|
||||
name: 'Authors',
|
||||
view: 'authors',
|
||||
route: '/authors',
|
||||
path: '/v1/author/',
|
||||
noun: 'authors',
|
||||
nounOne: 'author',
|
||||
total: 128,
|
||||
pages: 6,
|
||||
middle: 3,
|
||||
middleSummary: 'Showing 51–75 of 128',
|
||||
lastRows: 3,
|
||||
lastSummary: 'Showing 126–128 of 128',
|
||||
searchTerm: 'mar',
|
||||
searchPlaceholder: 'Search by name or slug',
|
||||
emptyTitle: 'No authors match "zoe"',
|
||||
emptyTerm: 'zoe',
|
||||
searchFrom: 5,
|
||||
searchTotal: 31,
|
||||
longPage: 12,
|
||||
longTotal: 1284,
|
||||
longSummary: 'Showing 276–300 of 1,284',
|
||||
longLabel: 'Page 12 of 52',
|
||||
},
|
||||
]
|
||||
|
||||
function generatedSites(count: number, prefix = 'Site'): Site[] {
|
||||
const template = sitesFixture()[0]!
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
...template,
|
||||
id: index + 1,
|
||||
name: `${prefix} ${index + 1}`,
|
||||
key: `${prefix.toLowerCase()}-${index + 1}`,
|
||||
base_url: `https://${prefix.toLowerCase()}-${index + 1}.example`,
|
||||
is_default: index === 0,
|
||||
article_count: index,
|
||||
}))
|
||||
}
|
||||
|
||||
function generatedAuthors(count: number, prefix = 'Author'): Author[] {
|
||||
const template = authorsFixture()[1]!
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
...template,
|
||||
id: index + 1,
|
||||
name: `${prefix} ${index + 1}`,
|
||||
slug: `${prefix.toLowerCase()}-${index + 1}`,
|
||||
user_id: null,
|
||||
is_linked: false,
|
||||
article_count: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
function backendFor(screen: Screen, total = screen.total): Backend {
|
||||
const backend = createBackend()
|
||||
if (screen.view === 'sites') {
|
||||
backend.sites = generatedSites(total)
|
||||
} else {
|
||||
backend.authors = generatedAuthors(total)
|
||||
}
|
||||
return backend
|
||||
}
|
||||
|
||||
function listCalls(backend: Backend, screen: Screen) {
|
||||
return callsTo(backend, 'GET', screen.path).filter((call) => call.query.has('page'))
|
||||
}
|
||||
|
||||
function unpagedCalls(backend: Backend, screen: Screen) {
|
||||
return callsTo(backend, 'GET', screen.path).filter((call) => !call.query.has('page'))
|
||||
}
|
||||
|
||||
function summary() {
|
||||
return document.body.querySelector('[data-testid="paginator-summary"]')?.textContent?.trim()
|
||||
}
|
||||
|
||||
function countLine() {
|
||||
return document.body.querySelector('[data-testid="paginator-count"]')?.textContent?.trim()
|
||||
}
|
||||
|
||||
function control(label: string) {
|
||||
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
|
||||
}
|
||||
|
||||
function pageNav() {
|
||||
return document.body.querySelector('nav[aria-label="Pagination"]')
|
||||
}
|
||||
|
||||
function currentPage() {
|
||||
return pageNav()?.querySelector('[aria-current="page"]')?.textContent?.trim()
|
||||
}
|
||||
|
||||
function sizeTrigger() {
|
||||
return document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')
|
||||
}
|
||||
|
||||
function alertText() {
|
||||
return textOf(document.body.querySelector('[role="alert"]'))
|
||||
}
|
||||
|
||||
async function chooseSize(label: string) {
|
||||
sizeTrigger()?.click()
|
||||
await flushPromises()
|
||||
const option = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')).find(
|
||||
(element) => element.textContent?.trim() === label,
|
||||
)
|
||||
option!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function searchFor(term: string) {
|
||||
const input = document.body.querySelector<HTMLInputElement>('input[type="search"]')!
|
||||
await typeInto(input, term)
|
||||
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
setLocale('en')
|
||||
await unmountDirectory()
|
||||
})
|
||||
|
||||
describe.each(SCREENS)('$name: pagination', (screen) => {
|
||||
it('1 First page', async () => {
|
||||
const backend = backendFor(screen)
|
||||
await mountDirectory(screen.view, screen.route, { backend })
|
||||
|
||||
expect(listCalls(backend, screen).map((call) => call.raw)).toEqual([
|
||||
`${screen.path}?page=1&size=25`,
|
||||
])
|
||||
expect(summary()).toBe(`Showing 1–25 of ${screen.total}`)
|
||||
expect(rows()).toHaveLength(25)
|
||||
expect(sizeTrigger()?.textContent).toContain('25 per page')
|
||||
expect(control('First page')?.disabled).toBe(true)
|
||||
expect(control('Previous page')?.disabled).toBe(true)
|
||||
expect(control('Next page')?.disabled).toBe(false)
|
||||
expect(control('Last page')?.disabled).toBe(false)
|
||||
expect(currentPage()).toBe('1')
|
||||
expect(router.currentRoute.value.fullPath).toBe(screen.route)
|
||||
})
|
||||
|
||||
it('1 First page, below md: Page 1 of N with Previous disabled', async () => {
|
||||
await mountDirectory(screen.view, screen.route, {
|
||||
backend: backendFor(screen),
|
||||
width: MOBILE_WIDTH,
|
||||
})
|
||||
|
||||
expect(textOf(pageNav())).toContain(`Page 1 of ${screen.pages}`)
|
||||
expect(control('Previous page')?.disabled).toBe(true)
|
||||
expect(control('Next page')?.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('2 Middle page', async () => {
|
||||
const backend = backendFor(screen)
|
||||
await mountDirectory(screen.view, `${screen.route}?page=${screen.middle}`, { backend })
|
||||
|
||||
expect(summary()).toBe(screen.middleSummary)
|
||||
expect(currentPage()).toBe(String(screen.middle))
|
||||
for (const label of ['First page', 'Previous page', 'Next page', 'Last page']) {
|
||||
expect(control(label)?.disabled).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('3 Last page', async () => {
|
||||
const backend = backendFor(screen)
|
||||
await mountDirectory(screen.view, `${screen.route}?page=${screen.pages}`, { backend })
|
||||
|
||||
expect(summary()).toBe(screen.lastSummary)
|
||||
expect(rows()).toHaveLength(screen.lastRows)
|
||||
expect(control('Next page')?.disabled).toBe(true)
|
||||
expect(control('Last page')?.disabled).toBe(true)
|
||||
expect(control('Previous page')?.disabled).toBe(false)
|
||||
expect(control('First page')?.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('4 Single page', async () => {
|
||||
const total = screen.view === 'sites' ? 4 : 6
|
||||
await mountDirectory(screen.view, screen.route, { backend: backendFor(screen, total) })
|
||||
|
||||
expect(countLine()).toBe(`${total} ${screen.noun}`)
|
||||
expect(pageNav()).toBeNull()
|
||||
expect(sizeTrigger()).toBeNull()
|
||||
expect(summary()).toBeUndefined()
|
||||
expect(document.body.textContent).not.toContain('Showing')
|
||||
})
|
||||
|
||||
it('4b Singular count line', async () => {
|
||||
await mountDirectory(screen.view, screen.route, { backend: backendFor(screen, 1) })
|
||||
|
||||
expect(countLine()).toBe(`1 ${screen.nounOne}`)
|
||||
expect(sizeTrigger()).toBeNull()
|
||||
})
|
||||
|
||||
it('4b Count line in French', async () => {
|
||||
setLocale('fr')
|
||||
const total = 6
|
||||
await mountDirectory(screen.view, screen.route, { backend: backendFor(screen, total) })
|
||||
|
||||
expect(countLine()).toBe(screen.view === 'sites' ? '6 sites' : '6 auteurs')
|
||||
})
|
||||
|
||||
it('4c Five per page with a few rows still pages', async () => {
|
||||
const backend = backendFor(screen, 6)
|
||||
await mountDirectory(screen.view, `${screen.route}?size=5`, { backend })
|
||||
|
||||
expect(listCalls(backend, screen)[0]!.raw).toBe(`${screen.path}?page=1&size=5`)
|
||||
expect(summary()).toBe('Showing 1–5 of 6')
|
||||
expect(sizeTrigger()?.textContent).toContain('5 per page')
|
||||
expect(pageNav()?.querySelectorAll('button[aria-current]')).toHaveLength(1)
|
||||
expect(control('Next page')?.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['5 per page', 5, 'size=5'],
|
||||
['25 per page', 25, ''],
|
||||
['50 per page', 50, 'size=50'],
|
||||
['100 per page', 100, 'size=100'],
|
||||
])('5 Page size menu: %s', async (label, size, search) => {
|
||||
const backend = backendFor(screen)
|
||||
await mountDirectory(screen.view, `${screen.route}?page=${screen.middle}`, { backend })
|
||||
|
||||
await chooseSize(label)
|
||||
|
||||
expect(listCalls(backend, screen).at(-1)!.raw).toBe(`${screen.path}?page=1&size=${size}`)
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
search ? `${screen.route}?${search}` : screen.route,
|
||||
)
|
||||
expect(router.currentRoute.value.query.page).toBeUndefined()
|
||||
expect(pageNav() === null || currentPage() === '1').toBe(true)
|
||||
})
|
||||
|
||||
it('5 Page size menu: ArrowDown opens, Escape closes', async () => {
|
||||
await mountDirectory(screen.view, screen.route, { backend: backendFor(screen) })
|
||||
const trigger = sizeTrigger()!
|
||||
trigger.focus()
|
||||
|
||||
trigger.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[role="listbox"]')).not.toBeNull()
|
||||
;(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['desktop', DESKTOP_WIDTH, 8],
|
||||
['below md', MOBILE_WIDTH, 6],
|
||||
])('6 Loading a page on %s', async (_name, width, skeletonRows) => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const backend = backendFor(screen)
|
||||
await mountDirectory(screen.view, `${screen.route}?page=${screen.middle}`, {
|
||||
backend,
|
||||
width,
|
||||
})
|
||||
const next = screen.middle + 1
|
||||
const hold = pending<unknown>()
|
||||
backend.responders[`GET ${screen.path}?page=${next}&size=25`] = () => hold.promise
|
||||
|
||||
control('Next page')!.click()
|
||||
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
|
||||
).toHaveLength(skeletonRows)
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
|
||||
expect(summary()).toBe(`Loading page ${next}…`)
|
||||
expect(sizeTrigger()?.hasAttribute('disabled')).toBe(true)
|
||||
expect(control('Next page')?.disabled).toBe(true)
|
||||
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=${screen.middle}`)
|
||||
if (width === DESKTOP_WIDTH) {
|
||||
expect(currentPage()).toBe(String(screen.middle))
|
||||
} else {
|
||||
expect(textOf(pageNav())).toContain(`Page ${screen.middle} of`)
|
||||
}
|
||||
|
||||
hold.resolve(
|
||||
paged(
|
||||
screen.view === 'sites'
|
||||
? generatedSites(25, 'Next')
|
||||
: { items: generatedAuthors(25, 'Next'), total: screen.total },
|
||||
screen.total,
|
||||
),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=${next}`)
|
||||
expect(rows()).toHaveLength(25)
|
||||
expect(summary()).toContain(`of ${screen.total}`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a 500 response', failWith(500)],
|
||||
['a network failure', networkFailure],
|
||||
])('7 Error loading a page with %s keeps the previous rows', async (_name, failure) => {
|
||||
const backend = backendFor(screen)
|
||||
await mountDirectory(screen.view, `${screen.route}?page=${screen.middle}`, { backend })
|
||||
const next = screen.middle + 1
|
||||
backend.responders[`GET ${screen.path}?page=${next}&size=25`] = failure
|
||||
|
||||
control('Next page')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(rows()).toHaveLength(25)
|
||||
expect(currentPage()).toBe(String(screen.middle))
|
||||
expect(control('Next page')?.disabled).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=${screen.middle}`)
|
||||
expect(alertText()).toContain(`Couldn't load page ${next}. Showing page ${screen.middle}.`)
|
||||
expect(alertText()).toContain('Check your connection and try again.')
|
||||
|
||||
delete backend.responders[`GET ${screen.path}?page=${next}&size=25`]
|
||||
await click('Retry')
|
||||
|
||||
expect(listCalls(backend, screen).at(-1)!.raw).toBe(`${screen.path}?page=${next}&size=25`)
|
||||
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=${next}`)
|
||||
expect(currentPage()).toBe(String(next))
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('7 A first-load failure keeps its own alert', async () => {
|
||||
const backend = backendFor(screen)
|
||||
backend.responders[`GET ${screen.path}`] = failWith(500)
|
||||
await mountDirectory(screen.view, screen.route, { backend })
|
||||
|
||||
expect(alertText()).toContain(`Couldn't load ${screen.noun}`)
|
||||
expect(alertText()).toContain('Try again')
|
||||
expect(rows()).toHaveLength(0)
|
||||
expect(summary()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('7 A response without X-Total-Count is a failed load', async () => {
|
||||
const backend = backendFor(screen)
|
||||
backend.responders[`GET ${screen.path}`] = () => {
|
||||
throw new MissingTotalError()
|
||||
}
|
||||
await mountDirectory(screen.view, screen.route, { backend })
|
||||
|
||||
expect(alertText()).toContain(`Couldn't load ${screen.noun}`)
|
||||
expect(rows()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('8 Empty after search', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const backend = backendFor(screen)
|
||||
await mountDirectory(screen.view, screen.route, { backend })
|
||||
backend.responders[`GET ${screen.path}?q=${screen.emptyTerm}&page=1&size=25`] = () =>
|
||||
screen.view === 'sites' ? paged([], 0) : { items: [], total: 0, page: 1, size: 25 }
|
||||
|
||||
await searchFor(screen.emptyTerm)
|
||||
|
||||
const text = textOf(document.body)
|
||||
expect(text).toContain(screen.emptyTitle)
|
||||
expect(text).toContain('Check the spelling, or search for a shorter part of the name.')
|
||||
expect(text).toContain('Clear search')
|
||||
expect(summary()).toBeUndefined()
|
||||
expect(countLine()).toBeUndefined()
|
||||
expect(pageNav()).toBeNull()
|
||||
|
||||
delete backend.responders[`GET ${screen.path}?q=${screen.emptyTerm}&page=1&size=25`]
|
||||
await click('Clear search')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(screen.route)
|
||||
expect(listCalls(backend, screen).at(-1)!.raw).toBe(`${screen.path}?page=1&size=25`)
|
||||
expect(rows().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('9 Search resets to page 1', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const backend = backendFor(screen)
|
||||
await mountDirectory(screen.view, `${screen.route}?page=${screen.searchFrom}`, {
|
||||
backend,
|
||||
})
|
||||
const matches = screen.searchTotal
|
||||
backend.responders[`GET ${screen.path}?q=${screen.searchTerm}&page=1&size=25`] = () =>
|
||||
screen.view === 'sites'
|
||||
? paged(generatedSites(25, 'Match'), matches)
|
||||
: { items: generatedAuthors(25, 'Match'), total: matches, page: 1, size: 25 }
|
||||
|
||||
await searchFor(screen.searchTerm)
|
||||
|
||||
expect(listCalls(backend, screen).at(-1)!.raw).toBe(
|
||||
`${screen.path}?q=${screen.searchTerm}&page=1&size=25`,
|
||||
)
|
||||
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?q=${screen.searchTerm}`)
|
||||
expect(summary()).toBe(`Showing 1–25 of ${matches}`)
|
||||
})
|
||||
|
||||
it('9 A size change resets the page', async () => {
|
||||
const backend = backendFor(screen)
|
||||
await mountDirectory(screen.view, `${screen.route}?page=${screen.middle}`, { backend })
|
||||
|
||||
await chooseSize('50 per page')
|
||||
|
||||
expect(listCalls(backend, screen).at(-1)!.raw).toBe(`${screen.path}?page=1&size=50`)
|
||||
expect(router.currentRoute.value.query.page).toBeUndefined()
|
||||
})
|
||||
|
||||
it('10 Long labels on mobile', async () => {
|
||||
const backend = backendFor(screen)
|
||||
backend.responders[`GET ${screen.path}?page=${screen.longPage}&size=25`] = () =>
|
||||
screen.view === 'sites'
|
||||
? paged(generatedSites(25, 'Long'), screen.longTotal)
|
||||
: {
|
||||
items: generatedAuthors(25, 'Long'),
|
||||
total: screen.longTotal,
|
||||
page: screen.longPage,
|
||||
size: 25,
|
||||
}
|
||||
await mountDirectory(screen.view, `${screen.route}?page=${screen.longPage}`, {
|
||||
backend,
|
||||
width: MOBILE_WIDTH,
|
||||
})
|
||||
|
||||
expect(summary()).toBe(screen.longSummary)
|
||||
const label = Array.from(pageNav()!.querySelectorAll('span')).find(
|
||||
(element) => element.textContent?.trim() === screen.longLabel,
|
||||
)!
|
||||
expect(label.className).toContain('whitespace-nowrap')
|
||||
expect(control('Previous page')?.className).toContain('flex-1')
|
||||
expect(control('Next page')?.className).toContain('flex-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each(SCREENS)('$name: URL rules', (screen) => {
|
||||
it('Invalid values fall back silently', async () => {
|
||||
const backend = backendFor(screen)
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
const push = vi.spyOn(router, 'push')
|
||||
await mountDirectory(screen.view, `${screen.route}?size=30&page=abc`, { backend })
|
||||
const pushesAfterMount = push.mock.calls.length
|
||||
|
||||
expect(listCalls(backend, screen).map((call) => call.raw)).toEqual([
|
||||
`${screen.path}?page=1&size=25`,
|
||||
])
|
||||
expect(replace).toHaveBeenCalledWith({ query: {} })
|
||||
expect(push.mock.calls.length).toBe(pushesAfterMount)
|
||||
expect(router.currentRoute.value.fullPath).toBe(screen.route)
|
||||
expect(document.body.querySelector('[role="status"]')).toBeNull()
|
||||
replace.mockRestore()
|
||||
push.mockRestore()
|
||||
})
|
||||
|
||||
it.each(['0', '-2'])('Page %s falls back to page 1', async (page) => {
|
||||
const backend = backendFor(screen)
|
||||
await mountDirectory(screen.view, `${screen.route}?page=${page}`, { backend })
|
||||
|
||||
expect(listCalls(backend, screen)[0]!.raw).toBe(`${screen.path}?page=1&size=25`)
|
||||
expect(router.currentRoute.value.fullPath).toBe(screen.route)
|
||||
})
|
||||
|
||||
it('A page out of range goes to the last page with a notice', async () => {
|
||||
const backend = backendFor(screen)
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
await mountDirectory(screen.view, `${screen.route}?page=99`, { backend })
|
||||
|
||||
expect(listCalls(backend, screen).map((call) => call.raw)).toEqual([
|
||||
`${screen.path}?page=99&size=25`,
|
||||
`${screen.path}?page=${screen.pages}&size=25`,
|
||||
])
|
||||
expect(replace).toHaveBeenCalledWith({ query: { page: String(screen.pages) } })
|
||||
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=${screen.pages}`)
|
||||
expect(textOf(document.body.querySelector('[role="status"]'))).toContain(
|
||||
"That page doesn't exist. Showing the last page.",
|
||||
)
|
||||
expect(summary()).toBe(screen.lastSummary)
|
||||
replace.mockRestore()
|
||||
})
|
||||
|
||||
it('A page out of range with a total of 0 shows the empty state', async () => {
|
||||
const backend = backendFor(screen, 0)
|
||||
await mountDirectory(screen.view, `${screen.route}?page=5`, { backend })
|
||||
|
||||
expect(listCalls(backend, screen)).toHaveLength(1)
|
||||
expect(document.body.querySelector('[role="status"]')).toBeNull()
|
||||
expect(router.currentRoute.value.fullPath).toBe(screen.route)
|
||||
expect(rows()).toHaveLength(0)
|
||||
expect(pageNav()).toBeNull()
|
||||
expect(countLine()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('Combined q and page in the URL', async () => {
|
||||
const backend = backendFor(screen)
|
||||
await mountDirectory(screen.view, `${screen.route}?q=${screen.searchTerm}&page=2`, {
|
||||
backend,
|
||||
})
|
||||
|
||||
expect(listCalls(backend, screen)[0]!.raw).toBe(
|
||||
`${screen.path}?q=${screen.searchTerm}&page=2&size=25`,
|
||||
)
|
||||
expect(document.body.querySelector<HTMLInputElement>('input[type="search"]')!.value).toBe(
|
||||
screen.searchTerm,
|
||||
)
|
||||
})
|
||||
|
||||
it('Browser Back returns to the previous page of results', async () => {
|
||||
const backend = backendFor(screen)
|
||||
await mountDirectory(screen.view, screen.route, { backend })
|
||||
control('Next page')!.click()
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=2`)
|
||||
|
||||
router.back()
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(screen.route)
|
||||
expect(listCalls(backend, screen).at(-1)!.raw).toBe(`${screen.path}?page=1&size=25`)
|
||||
expect(currentPage()).toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites: search', () => {
|
||||
it('shows the Sites search field and sends q', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const backend = backendFor(SCREENS[0]!)
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
expect(
|
||||
document.body.querySelector('input[type="search"]')?.getAttribute('placeholder'),
|
||||
).toBe('Search by name or key')
|
||||
|
||||
await searchFor('renew')
|
||||
|
||||
expect(listCalls(backend, SCREENS[0]!).at(-1)!.raw).toBe('/v1/site/?q=renew&page=1&size=25')
|
||||
})
|
||||
|
||||
it('has a French placeholder', async () => {
|
||||
setLocale('fr')
|
||||
await mountDirectory('sites', '/settings/sites', { backend: backendFor(SCREENS[0]!) })
|
||||
|
||||
expect(
|
||||
document.body.querySelector('input[type="search"]')?.getAttribute('placeholder'),
|
||||
).toBe('Rechercher par nom ou clé')
|
||||
})
|
||||
|
||||
it('a search that matches only one site lists what the API returns', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const backend = backendFor(SCREENS[0]!)
|
||||
backend.responders['GET /v1/site/?q=journal&page=1&size=25'] = () =>
|
||||
paged([{ ...generatedSites(1)[0]!, name: 'Journal', is_default: false }], 1)
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
|
||||
await searchFor('journal')
|
||||
|
||||
expect(rows()).toHaveLength(1)
|
||||
expect(textOf(rows()[0])).toContain('Journal')
|
||||
expect(countLine()).toBe('1 site')
|
||||
expect(document.body.textContent).not.toContain('Add another site')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites: the unpaged list feeds the dependents', () => {
|
||||
it('?edit=journal reads GET /v1/site/ without page and size', async () => {
|
||||
const backend = backendFor(SCREENS[0]!)
|
||||
backend.sites = [
|
||||
...generatedSites(60),
|
||||
{
|
||||
...generatedSites(1)[0]!,
|
||||
id: 900,
|
||||
name: 'Journal',
|
||||
key: 'journal',
|
||||
is_default: false,
|
||||
},
|
||||
]
|
||||
await mountDirectory('sites', '/settings/sites?page=2&edit=journal', { backend })
|
||||
|
||||
expect(unpagedCalls(backend, SCREENS[0]!)).toHaveLength(1)
|
||||
expect(unpagedCalls(backend, SCREENS[0]!)[0]!.raw).toBe('/v1/site/')
|
||||
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
expect(textOf(document.body.querySelector('[role="dialog"]'))).toContain('Edit Journal')
|
||||
})
|
||||
|
||||
it('the add form checks duplicate keys against the unpaged list', async () => {
|
||||
const backend = backendFor(SCREENS[0]!)
|
||||
backend.sites = [
|
||||
...generatedSites(60),
|
||||
{
|
||||
...generatedSites(1)[0]!,
|
||||
id: 900,
|
||||
name: 'Journal',
|
||||
key: 'journal',
|
||||
is_default: false,
|
||||
},
|
||||
]
|
||||
await mountDirectory('sites', '/settings/sites?page=3', { backend })
|
||||
|
||||
await click('Add site')
|
||||
await typeInto(field('e.g. Journal'), 'Journal')
|
||||
await typeInto(field('https://blog.example.com'), 'https://journal2.example')
|
||||
const key = labelled('Key') as HTMLInputElement
|
||||
await blur(key)
|
||||
|
||||
expect(unpagedCalls(backend, SCREENS[0]!)).toHaveLength(1)
|
||||
expect(textOf(dialog())).toContain(
|
||||
'A site with the key "journal" already exists. Choose another key.',
|
||||
)
|
||||
})
|
||||
|
||||
it('the delete dialog offers destinations from the unpaged list', async () => {
|
||||
const backend = backendFor(SCREENS[0]!)
|
||||
await mountDirectory('sites', '/settings/sites?page=2', { backend })
|
||||
const before = unpagedCalls(backend, SCREENS[0]!).length
|
||||
|
||||
const trigger = document.body.querySelector<HTMLElement>('[aria-label^="Actions for"]')!
|
||||
trigger.click()
|
||||
await flushPromises()
|
||||
const remove = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).find((item) => textOf(item) === 'Delete…')!
|
||||
remove.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(unpagedCalls(backend, SCREENS[0]!).length).toBe(before + 1)
|
||||
expect(unpagedCalls(backend, SCREENS[0]!).at(-1)!.raw).toBe('/v1/site/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors: roles', () => {
|
||||
it('member sees the same footer with no Add author button and no row menu', async () => {
|
||||
await mountDirectory('authors', '/authors?page=2', {
|
||||
backend: backendFor(SCREENS[1]!),
|
||||
role: 'member',
|
||||
})
|
||||
|
||||
expect(summary()).toBe('Showing 26–50 of 128')
|
||||
expect(currentPage()).toBe('2')
|
||||
expect(document.body.textContent).not.toContain('Add author')
|
||||
expect(document.body.querySelector('[aria-label^="Actions for"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('admin sees the same footer with the Add author button and the row menu', async () => {
|
||||
await mountDirectory('authors', '/authors?page=2', {
|
||||
backend: backendFor(SCREENS[1]!),
|
||||
role: 'admin',
|
||||
})
|
||||
|
||||
expect(summary()).toBe('Showing 26–50 of 128')
|
||||
expect(currentPage()).toBe('2')
|
||||
expect(document.body.textContent).toContain('Add author')
|
||||
expect(document.body.querySelector('[aria-label^="Actions for"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors: list changes', () => {
|
||||
it('deleting the last row of the last page requests the previous page', async () => {
|
||||
const backend = backendFor(SCREENS[1]!, 26)
|
||||
await mountDirectory('authors', '/authors?page=2', { backend })
|
||||
expect(rows()).toHaveLength(1)
|
||||
|
||||
const trigger = document.body.querySelector<HTMLElement>('[aria-label^="Actions for"]')!
|
||||
trigger.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.find((item) => textOf(item) === 'Delete…')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
const confirm = Array.from(document.body.querySelectorAll<HTMLElement>('button')).find(
|
||||
(item) => textOf(item) === 'Delete author',
|
||||
)!
|
||||
confirm.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(listCalls(backend, SCREENS[1]!).at(-1)!.raw).toBe('/v1/author/?page=1&size=25')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/authors')
|
||||
})
|
||||
|
||||
it('a 26th row turns the count line into the pager', async () => {
|
||||
const backend = backendFor(SCREENS[1]!, 25)
|
||||
await mountDirectory('authors', '/authors', { backend })
|
||||
expect(countLine()).toBe('25 authors')
|
||||
|
||||
await click('Add author')
|
||||
const form = dialog()!
|
||||
await typeInto(field('e.g. Camille Roche'), 'Zoe Lambert')
|
||||
await click('Add author', form)
|
||||
|
||||
expect(summary()).toBe('Showing 1–25 of 26')
|
||||
expect(countLine()).toBeUndefined()
|
||||
expect(pageNav()).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -184,17 +184,19 @@ describe('Sites form: add succeeds', () => {
|
||||
|
||||
expect(button('Saving…', box())).toBeTruthy()
|
||||
expect(field('e.g. Journal').disabled).toBe(true)
|
||||
hold.resolve({
|
||||
const created = {
|
||||
id: 5,
|
||||
name: 'Notes',
|
||||
key: 'notes',
|
||||
base_url: 'https://notes.example',
|
||||
default_social_image_id: null,
|
||||
default_robots: 'noindex',
|
||||
default_robots: 'noindex' as const,
|
||||
locale: null,
|
||||
is_default: false,
|
||||
article_count: 0,
|
||||
})
|
||||
}
|
||||
backend.sites.push(created)
|
||||
hold.resolve(created)
|
||||
await flushPromises()
|
||||
|
||||
const posts = callsTo(backend, 'POST', '/v1/site/')
|
||||
|
||||
@@ -21,6 +21,20 @@ export interface Call {
|
||||
body: Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
export class PagedReply {
|
||||
body: unknown
|
||||
total: number
|
||||
|
||||
constructor(body: unknown, total: number) {
|
||||
this.body = body
|
||||
this.total = total
|
||||
}
|
||||
}
|
||||
|
||||
export function paged(body: unknown, total: number) {
|
||||
return new PagedReply(body, total)
|
||||
}
|
||||
|
||||
export type Responder = (call: Call) => unknown | Promise<unknown>
|
||||
|
||||
export interface Backend {
|
||||
@@ -177,12 +191,25 @@ function pageOf(backend: Backend, query: URLSearchParams) {
|
||||
}
|
||||
}
|
||||
|
||||
function sitePageOf(backend: Backend, query: URLSearchParams) {
|
||||
const q = (query.get('q') ?? '').toLowerCase()
|
||||
const size = Number(query.get('size') ?? 25)
|
||||
const page = Number(query.get('page') ?? 1)
|
||||
const matching = backend.sites.filter((item) =>
|
||||
`${item.name} ${item.key} ${item.base_url}`.toLowerCase().includes(q),
|
||||
)
|
||||
return new PagedReply(
|
||||
structuredClone(matching.slice((page - 1) * size, page * size)),
|
||||
matching.length,
|
||||
)
|
||||
}
|
||||
|
||||
export function defaultRoute(backend: Backend, call: Call): unknown {
|
||||
const { method, path, query, body } = call
|
||||
const siteMatch = /^\/v1\/site\/(\d+)(\/default)?$/.exec(path)
|
||||
const authorMatch = /^\/v1\/author\/(\d+)$/.exec(path)
|
||||
if (method === 'GET' && path === '/v1/site/') {
|
||||
return structuredClone(backend.sites)
|
||||
return query.has('page') ? sitePageOf(backend, query) : structuredClone(backend.sites)
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/site/') {
|
||||
if (backend.sites.some((site) => site.key === body?.key)) {
|
||||
@@ -349,9 +376,20 @@ export async function mountDirectory(
|
||||
backend.responders[`${call.method} ${pathname}`]
|
||||
return responder ? responder(call) : defaultRoute(backend, call)
|
||||
})
|
||||
const requestPaged = vi.fn(async (path: string, init?: RequestInit) => {
|
||||
const result = await request(path, init)
|
||||
if (result instanceof PagedReply) {
|
||||
return { body: result.body, total: result.total }
|
||||
}
|
||||
if (Array.isArray(result)) {
|
||||
return { body: result, total: result.length }
|
||||
}
|
||||
return { body: result, total: (result as { total: number }).total }
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
|
||||
request,
|
||||
requestPaged,
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
await router.push(url)
|
||||
const component = view === 'sites' ? SitesView : AuthorsView
|
||||
|
||||
Reference in new issue
Block a user