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,640 @@
|
||||
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,
|
||||
button,
|
||||
calls,
|
||||
customTypes,
|
||||
eventEntries,
|
||||
failWith,
|
||||
failWithBody,
|
||||
listPage,
|
||||
listRequests,
|
||||
lookupNames,
|
||||
lookupRequests,
|
||||
manyTypes,
|
||||
mountList,
|
||||
networkFailure,
|
||||
pending,
|
||||
populated,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './contentTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
const NOT_FOUND = failWithBody(404, 'content_type_not_found')
|
||||
const LONG_NAME = 'Frequently asked questions and answers'
|
||||
|
||||
function main() {
|
||||
return document.body.querySelector('#dashboard-content')!
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return [...main().querySelectorAll('thead th')].map(text)
|
||||
}
|
||||
|
||||
function rows() {
|
||||
return [...main().querySelectorAll('tbody tr')]
|
||||
}
|
||||
|
||||
function cells(row: Element) {
|
||||
return [...row.querySelectorAll('td')].map(text)
|
||||
}
|
||||
|
||||
function skeletonRows() {
|
||||
return main().querySelectorAll('[data-testid="entry-skeleton-row"]')
|
||||
}
|
||||
|
||||
function topBarTitle() {
|
||||
return text(document.body.querySelector('h1[data-slot="title"]'))
|
||||
}
|
||||
|
||||
function breadcrumb() {
|
||||
return text(document.body.querySelector('nav[aria-label="breadcrumb"]'))
|
||||
}
|
||||
|
||||
function currentSidebarRows() {
|
||||
return document.body.querySelectorAll('nav[aria-label="Content"] a[aria-current="page"]')
|
||||
}
|
||||
|
||||
async function afterDelay() {
|
||||
vi.advanceTimersByTime(SKELETON_DELAY_MS)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('1 Populated, one page', () => {
|
||||
it('shows the breadcrumb, the title and the subtitle', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
expect(breadcrumb()).toBe('Content Events')
|
||||
expect(main().querySelector('h1')?.textContent).toBe('Events')
|
||||
expect(text(main())).toContain('events · 7 fields')
|
||||
})
|
||||
|
||||
it('puts New entry in the title row and the toolbar below it', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
expect(button('New entry')?.hasAttribute('disabled')).toBe(false)
|
||||
const toolbar = main().querySelector('[data-testid="entry-toolbar"]')!
|
||||
expect(text(toolbar)).toBe('Sort by Last updated Columns Edit content type')
|
||||
expect(toolbar.querySelector('[aria-label="Sort ascending"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('reads the headers with the arrow on Updated', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
expect(headers()).toEqual([
|
||||
'ID',
|
||||
'Title',
|
||||
'Starts on',
|
||||
'Ends on',
|
||||
'Online',
|
||||
'Created',
|
||||
'Updated',
|
||||
'Updated by',
|
||||
])
|
||||
const updated = [...main().querySelectorAll('thead th')][6]!
|
||||
expect(updated.querySelector('[data-sort-direction="desc"]')).not.toBeNull()
|
||||
expect(updated.getAttribute('aria-sort')).toBe('descending')
|
||||
expect(main().querySelectorAll('thead th [data-sort-direction]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('leaves the first field column to take the free width and fixes the others', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
const [, first, second] = [...main().querySelectorAll('thead th')]
|
||||
|
||||
expect(first!.className).not.toMatch(/\bw-\d/)
|
||||
expect(second!.className).toMatch(/\bw-\d/)
|
||||
})
|
||||
|
||||
it('shows the seven rows, the first with its values', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
expect(rows()).toHaveLength(7)
|
||||
expect(cells(rows()[0]!)).toEqual([
|
||||
'42',
|
||||
'Lime plaster workshop',
|
||||
'21 Nov 2026',
|
||||
'22 Nov 2026',
|
||||
'No',
|
||||
'12 Oct 2026',
|
||||
'15 Oct 2026',
|
||||
'Marta Alves',
|
||||
])
|
||||
expect(cells(rows()[1]!)).toContain('–')
|
||||
})
|
||||
|
||||
it('shows only the count, no pager and no page size select, and leaves the URL bare', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
expect(text(main().querySelector('[data-testid="paginator-count"]'))).toBe('7 entries')
|
||||
expect(main().querySelector('nav[aria-label="Pagination"]')).toBeNull()
|
||||
expect(document.body.querySelector('[aria-label="Rows per page"]')).toBeNull()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events')
|
||||
expect(
|
||||
document.body.querySelector('nav[aria-label="Content"] a[aria-current="page"]')
|
||||
?.textContent,
|
||||
).toContain('Events')
|
||||
})
|
||||
|
||||
it('reads "1 entry" for a single entry', async () => {
|
||||
await mountList('/content/events', {
|
||||
list: (_, query) => listPage(eventEntries.slice(0, 1), 1, query),
|
||||
})
|
||||
|
||||
expect(text(main().querySelector('[data-testid="paginator-count"]'))).toBe('1 entry')
|
||||
})
|
||||
|
||||
it('reads "1 field" for a one-field type', async () => {
|
||||
await mountList('/content/events', {
|
||||
type: () => ({ ...customTypes[0]!, fields: customTypes[0]!.fields.slice(0, 1) }),
|
||||
})
|
||||
|
||||
expect(text(main())).toContain('events · 1 field')
|
||||
})
|
||||
|
||||
it('titles the top bar with the type name', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(topBarTitle()).toBe('Events')
|
||||
})
|
||||
|
||||
it('requests the type and the entries with explicit page, size and sort', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
expect(calls).toContain('/v1/content-type/events')
|
||||
expect(listRequests(calls)).toEqual([
|
||||
'/v1/content-type/events/entry?page=1&size=25&sort=updated_at:desc',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Updated by lookup', () => {
|
||||
it('sends one lookup with the distinct ids after the entries answer', async () => {
|
||||
await mountList('/content/events', {
|
||||
list: (_, query) =>
|
||||
listPage(
|
||||
[
|
||||
{ ...eventEntries[0]!, updated_by: 3 },
|
||||
{ ...eventEntries[1]!, updated_by: 3 },
|
||||
{ ...eventEntries[2]!, updated_by: 5 },
|
||||
],
|
||||
3,
|
||||
query,
|
||||
),
|
||||
})
|
||||
|
||||
expect(lookupRequests(calls)).toEqual(['/v1/user/lookup?ids=3,5'])
|
||||
expect(cells(rows()[0]!).at(-1)).toBe('Marta Alves')
|
||||
expect(cells(rows()[2]!).at(-1)).toBe('Jonas Pereira')
|
||||
})
|
||||
|
||||
it('sends no lookup before the entries answer', async () => {
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { list: () => gate.promise })
|
||||
|
||||
expect(lookupRequests(calls)).toEqual([])
|
||||
})
|
||||
|
||||
it('shows a muted dash until the lookup resolves', async () => {
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { lookup: () => gate.promise })
|
||||
|
||||
expect(cells(rows()[0]!).at(-1)).toBe('–')
|
||||
|
||||
gate.resolve(lookupNames([3, 4, 5]))
|
||||
await flushPromises()
|
||||
|
||||
expect(cells(rows()[0]!).at(-1)).toBe('Marta Alves')
|
||||
})
|
||||
|
||||
it('shows dashes and no error when the lookup fails', async () => {
|
||||
await mountList('/content/events', { lookup: failWith(500) })
|
||||
|
||||
expect(rows().map((row) => cells(row).at(-1))).toEqual(new Array(7).fill('–'))
|
||||
expect(main().querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a dash for an id the lookup does not know', async () => {
|
||||
await mountList('/content/events', { lookup: () => lookupNames([3]) })
|
||||
|
||||
expect(cells(rows()[0]!).at(-1)).toBe('Marta Alves')
|
||||
expect(cells(rows()[1]!).at(-1)).toBe('–')
|
||||
})
|
||||
|
||||
it('leaves entries without an author out of the lookup', async () => {
|
||||
await mountList('/content/events', {
|
||||
list: (_, query) =>
|
||||
listPage(
|
||||
[
|
||||
{ ...eventEntries[0]!, updated_by: 0 },
|
||||
{ ...eventEntries[1]!, updated_by: 3 },
|
||||
],
|
||||
2,
|
||||
query,
|
||||
),
|
||||
})
|
||||
|
||||
expect(lookupRequests(calls)).toEqual(['/v1/user/lookup?ids=3'])
|
||||
})
|
||||
|
||||
it('sends no lookup when no entry has an author', async () => {
|
||||
await mountList('/content/events', {
|
||||
list: (_, query) => listPage([{ ...eventEntries[0]!, updated_by: 0 }], 1, query),
|
||||
})
|
||||
|
||||
expect(lookupRequests(calls)).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores a lookup answer that a later page replaced', async () => {
|
||||
const slow = pending()
|
||||
await mountList('/content/events?size=5', {
|
||||
list: (_, query) =>
|
||||
query.get('page') === '2'
|
||||
? listPage([{ ...eventEntries[0]!, updated_by: 4 }], 37, query)
|
||||
: listPage([{ ...eventEntries[0]!, updated_by: 3 }], 37, query),
|
||||
lookup: (ids) => (ids[0] === 3 ? slow.promise : lookupNames([4])),
|
||||
})
|
||||
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Next page"]')!.click()
|
||||
await flushPromises()
|
||||
slow.resolve([{ id: 4, display_name: 'Stale Name' }])
|
||||
await flushPromises()
|
||||
|
||||
expect(cells(rows()[0]!).at(-1)).toBe('Inès Duarte')
|
||||
})
|
||||
|
||||
it('sends no lookup for an empty list', async () => {
|
||||
await mountList('/content/events', { list: (_, query) => listPage([], 0, query) })
|
||||
|
||||
expect(lookupRequests(calls)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('4 Many types in the sidebar', () => {
|
||||
const types = () => [
|
||||
customTypes[0]!,
|
||||
...manyTypes(13).map((type, index) => ({
|
||||
...type,
|
||||
name: index === 2 ? LONG_NAME : type.name,
|
||||
})),
|
||||
]
|
||||
|
||||
function contentLinks() {
|
||||
return [...document.body.querySelectorAll('nav[aria-label="Content"] a')].map(text)
|
||||
}
|
||||
|
||||
it('shows the first five types and Show all 14 on desktop, behaving as before', async () => {
|
||||
await mountList('/content/events', { content: types })
|
||||
|
||||
expect(contentLinks()).toEqual(['Events', 'Type 1', 'Type 2', LONG_NAME, 'Type 4'])
|
||||
expect(text(document.body.querySelector('nav[aria-label="Content"]'))).toContain(
|
||||
'Show all 14',
|
||||
)
|
||||
expect(currentSidebarRows()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the collapsed All content types 14 row in the mobile drawer', async () => {
|
||||
await mountList('/content/events', { content: types, width: MOBILE_WIDTH })
|
||||
document.body.querySelector<HTMLElement>('[data-slot="toggle"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const group = document.body.querySelector('nav[aria-label="Content"]')!
|
||||
expect(text(group)).toContain('All content types')
|
||||
expect(text(group)).toContain('14')
|
||||
expect(contentLinks()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('5 Loading', () => {
|
||||
it('shows the title once the type has resolved, a disabled New entry and the toolbar', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { list: () => gate.promise })
|
||||
await afterDelay()
|
||||
|
||||
expect(main().querySelector('h1')?.textContent).toBe('Events')
|
||||
expect(button('New entry')?.hasAttribute('disabled')).toBe(true)
|
||||
expect(main().querySelector('[data-testid="entry-toolbar"]')).not.toBeNull()
|
||||
expect(main().querySelector('table')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no skeleton for 150 ms, then eight rows with aria-busy', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { list: () => gate.promise })
|
||||
|
||||
expect(skeletonRows()).toHaveLength(0)
|
||||
|
||||
await afterDelay()
|
||||
|
||||
expect(skeletonRows()).toHaveLength(8)
|
||||
expect(main().querySelector('[aria-busy="true"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows six rows on mobile', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { list: () => gate.promise, width: MOBILE_WIDTH })
|
||||
await afterDelay()
|
||||
|
||||
expect(skeletonRows()).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('shows a skeleton for the title until the type resolves', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { type: () => gate.promise })
|
||||
|
||||
expect(main().querySelector('h1')).toBeNull()
|
||||
expect(main().querySelector('[data-slot="base"].bg-accented, .bg-accented')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('replaces the skeleton with the rows when both resolve', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { list: () => gate.promise })
|
||||
await afterDelay()
|
||||
|
||||
gate.resolve(populated('events', new URLSearchParams('page=1&size=25')))
|
||||
await flushPromises()
|
||||
|
||||
expect(skeletonRows()).toHaveLength(0)
|
||||
expect(rows()).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('keeps the skeleton while the type is slower than the entries', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { type: () => gate.promise })
|
||||
|
||||
expect(skeletonRows()).toHaveLength(8)
|
||||
expect(rows()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('6 Empty for this type', () => {
|
||||
const empty = (_: string, query: URLSearchParams) => listPage([], 0, query)
|
||||
|
||||
it('shows the empty state with its copy and action, and keeps the toolbar', async () => {
|
||||
await mountList('/content/events', { list: empty })
|
||||
|
||||
expect(text(main())).toContain('No entries yet')
|
||||
expect(text(main())).toContain(
|
||||
'Use New entry to add the first entry. Each entry follows the fields of this content type.',
|
||||
)
|
||||
expect(main().querySelector('[data-testid="entry-toolbar"]')).not.toBeNull()
|
||||
expect(main().querySelector('[data-testid="paginator-count"]')).toBeNull()
|
||||
expect(main().querySelector('nav[aria-label="Pagination"]')).toBeNull()
|
||||
expect(text(main())).not.toContain('0 entries')
|
||||
})
|
||||
|
||||
it('sends the empty state New entry action to /content/events/new', async () => {
|
||||
await mountList('/content/events', { list: empty })
|
||||
|
||||
const action = [...main().querySelectorAll('button')].filter(
|
||||
(candidate) => text(candidate) === 'New entry',
|
||||
)
|
||||
expect(action).toHaveLength(2)
|
||||
action.at(-1)!.click()
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events/new'),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the empty state for an out-of-range page of an empty type and cleans the URL', async () => {
|
||||
await mountList('/content/events?page=3', { list: empty })
|
||||
|
||||
expect(text(main())).toContain('No entries yet')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events')
|
||||
})
|
||||
})
|
||||
|
||||
describe('7 Error loading entries', () => {
|
||||
it.each([
|
||||
['a 500 response', failWith(500)],
|
||||
['a network failure', networkFailure],
|
||||
])('shows the alert for %s, without toolbar, list or pager', async (_, failure) => {
|
||||
await mountList('/content/events', { list: failure })
|
||||
|
||||
expect(text(main())).toContain("Couldn't load entries")
|
||||
expect(text(main())).toContain(
|
||||
'GET /v1/content-type/events/entry failed. Check your connection and try again.',
|
||||
)
|
||||
expect(button('Try again')).toBeDefined()
|
||||
expect(main().querySelector('[data-testid="entry-toolbar"]')).toBeNull()
|
||||
expect(main().querySelector('table')).toBeNull()
|
||||
expect(main().querySelector('nav[aria-label="Pagination"]')).toBeNull()
|
||||
expect(main().querySelector('h1')?.textContent).toBe('Events')
|
||||
})
|
||||
|
||||
it('shows the skeleton on Try again and sends the failed request again, not the type', async () => {
|
||||
vi.useFakeTimers()
|
||||
let attempt = 0
|
||||
const gate = pending()
|
||||
await mountList('/content/events', {
|
||||
list: (key, query) => {
|
||||
attempt++
|
||||
if (attempt === 1) {
|
||||
throw new Error('x')
|
||||
}
|
||||
return attempt === 2 ? gate.promise : populated(key, query)
|
||||
},
|
||||
})
|
||||
|
||||
button('Try again')!.click()
|
||||
await afterDelay()
|
||||
|
||||
expect(skeletonRows()).toHaveLength(8)
|
||||
expect(listRequests(calls)).toHaveLength(2)
|
||||
expect(calls.filter((url) => url === '/v1/content-type/events')).toHaveLength(1)
|
||||
|
||||
gate.resolve(populated('events', new URLSearchParams('page=1&size=25')))
|
||||
await flushPromises()
|
||||
|
||||
expect(rows()).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('shows no raw error body', async () => {
|
||||
await mountList('/content/events', { list: failWithBody(500, 'sql: secret detail') })
|
||||
|
||||
expect(text(main())).not.toContain('secret detail')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error loading the type', () => {
|
||||
it('shows the alert naming the type request and retries only that request', async () => {
|
||||
let attempt = 0
|
||||
await mountList('/content/events', {
|
||||
type: (key) => {
|
||||
attempt++
|
||||
if (attempt === 1) {
|
||||
throw new Error('x')
|
||||
}
|
||||
return customTypes.find((type) => type.key === key)
|
||||
},
|
||||
})
|
||||
|
||||
expect(text(main())).toContain("Couldn't load entries")
|
||||
expect(text(main())).toContain(
|
||||
'GET /v1/content-type/events failed. Check your connection and try again.',
|
||||
)
|
||||
expect(main().querySelector('[data-testid="entry-toolbar"]')).toBeNull()
|
||||
|
||||
button('Try again')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(calls.filter((url) => url === '/v1/content-type/events')).toHaveLength(2)
|
||||
expect(listRequests(calls)).toHaveLength(1)
|
||||
expect(rows()).toHaveLength(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('8 Forbidden (403)', () => {
|
||||
it.each([
|
||||
['the entries request', { list: failWith(403) }, 'Events'],
|
||||
['the type request', { type: failWith(403) }, 'events'],
|
||||
])('shows the lock state when %s answers 403, titled %s', async (_, options, name) => {
|
||||
await mountList('/content/events', options)
|
||||
|
||||
expect(text(main())).toContain(`You don't have access to ${name}`)
|
||||
expect(text(main())).toContain(
|
||||
"Your account can't open this content right now. If you think this is a mistake, ask an owner or an admin.",
|
||||
)
|
||||
expect(main().querySelector('[data-testid="entry-toolbar"]')).toBeNull()
|
||||
expect(main().querySelector('table')).toBeNull()
|
||||
expect(button('New entry')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('goes to Home from Go to Home', async () => {
|
||||
await mountList('/content/events', { list: failWith(403) })
|
||||
|
||||
button('Go to Home')!.click()
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('9 Type not found', () => {
|
||||
it.each([
|
||||
['the type request', { type: NOT_FOUND }],
|
||||
['the entries request', { list: NOT_FOUND }],
|
||||
])('shows the file-x state when %s answers 404', async (_, options) => {
|
||||
await mountList('/content/unknown', options)
|
||||
|
||||
expect(text(main())).toContain('Content type not found')
|
||||
expect(text(main())).toContain('It may have been deleted, or the address may be mistyped.')
|
||||
expect(main().querySelector('[data-testid="entry-toolbar"]')).toBeNull()
|
||||
expect(breadcrumb()).toBe('Content')
|
||||
expect(currentSidebarRows()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('titles the mobile top bar Content', async () => {
|
||||
await mountList('/content/unknown', {
|
||||
type: NOT_FOUND,
|
||||
list: NOT_FOUND,
|
||||
width: MOBILE_WIDTH,
|
||||
})
|
||||
|
||||
expect(topBarTitle()).toBe('Content')
|
||||
})
|
||||
|
||||
it('goes to Home from Go to Home', async () => {
|
||||
await mountList('/content/unknown', { type: NOT_FOUND, list: NOT_FOUND })
|
||||
|
||||
button('Go to Home')!.click()
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
|
||||
})
|
||||
|
||||
it('sends no lookup for a type that was deleted behind the sidebar', async () => {
|
||||
await mountList('/content/recipes', { type: NOT_FOUND, list: NOT_FOUND })
|
||||
|
||||
expect(text(main())).toContain('Content type not found')
|
||||
expect(lookupRequests(calls)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session expired and stale answers', () => {
|
||||
it('shows no error alert while a 401 sends the user to the login page', async () => {
|
||||
await mountList('/content/events', { list: failWith(401) })
|
||||
|
||||
expect(text(main())).not.toContain("Couldn't load entries")
|
||||
})
|
||||
|
||||
it('drops the answer of a request a later sort change replaced', async () => {
|
||||
const stale = pending()
|
||||
await mountList('/content/events?size=5', {
|
||||
list: (_, query) =>
|
||||
query.get('page') === '2'
|
||||
? stale.promise
|
||||
: listPage(eventEntries.slice(0, 5), 37, query),
|
||||
})
|
||||
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Next page"]')!.click()
|
||||
await flushPromises()
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Sort ascending"]')!.click()
|
||||
await flushPromises()
|
||||
stale.resolve(listPage(eventEntries.slice(2, 4), 37, new URLSearchParams('page=2&size=5')))
|
||||
await flushPromises()
|
||||
|
||||
expect(rows()).toHaveLength(5)
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
'/content/events?size=5&sort=updated_at:asc',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Switching to another type resets the page', () => {
|
||||
it('ignores the type answer of the type the user left', async () => {
|
||||
const gate = pending()
|
||||
await mountList('/content/events', {
|
||||
type: (key) =>
|
||||
key === 'events' ? gate.promise : customTypes.find((t) => t.key === key),
|
||||
})
|
||||
|
||||
await router.push('/content/recipes')
|
||||
await flushPromises()
|
||||
gate.resolve(customTypes.find((type) => type.key === 'events'))
|
||||
await flushPromises()
|
||||
|
||||
expect(main().querySelector('h1')?.textContent).toBe('Recipes')
|
||||
expect(text(main())).toContain('recipes')
|
||||
})
|
||||
|
||||
it('requests Recipes with page 1, size 25 and the default sort, and shows no Events row', async () => {
|
||||
await mountList('/content/events?page=3&size=50&sort=id:asc')
|
||||
expect(listRequests(calls).at(-1)).toContain('page=3&size=50&sort=id:asc')
|
||||
|
||||
await router.push('/content/recipes')
|
||||
await flushPromises()
|
||||
|
||||
expect(listRequests(calls).at(-1)).toBe(
|
||||
'/v1/content-type/recipes/entry?page=1&size=25&sort=updated_at:desc',
|
||||
)
|
||||
expect(text(main())).not.toContain('Lime plaster workshop')
|
||||
expect(main().querySelector('h1')?.textContent).toBe('Recipes')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/recipes')
|
||||
})
|
||||
|
||||
it('shows no Events row while Recipes is loading', async () => {
|
||||
await mountList('/content/events')
|
||||
const gate = pending()
|
||||
await router.push('/content/recipes')
|
||||
|
||||
expect(text(main())).not.toContain('Lime plaster workshop')
|
||||
expect(headers()).toEqual([])
|
||||
gate.resolve(undefined)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,454 @@
|
||||
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 {
|
||||
calls,
|
||||
customTypes,
|
||||
eventEntries,
|
||||
installSelectPolyfills,
|
||||
listPage,
|
||||
listRequests,
|
||||
mountList,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './contentTestKit'
|
||||
import { eventsFields } from '@/lib/__tests__/schemaFixtures'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeAll(installSelectPolyfills)
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
await unmountAll()
|
||||
})
|
||||
|
||||
const KEY = 'renewcms-content-columns-events'
|
||||
const DEFAULT_HEADERS = ['ID', 'Title', 'Starts on', 'Ends on', 'Online']
|
||||
const TAIL_HEADERS = ['Created', 'Updated', 'Updated by']
|
||||
const ALL_KEYS = eventsFields.map((field) => field.key)
|
||||
|
||||
function seed(value: string, key = KEY) {
|
||||
return () => localStorage.setItem(key, value)
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return [...document.body.querySelectorAll('thead th')].map(text)
|
||||
}
|
||||
|
||||
function trigger() {
|
||||
return [...document.body.querySelectorAll('button')].find(
|
||||
(candidate) =>
|
||||
text(candidate) === 'Columns' || candidate.getAttribute('aria-label') === 'Columns',
|
||||
) as HTMLElement
|
||||
}
|
||||
|
||||
async function openPicker() {
|
||||
trigger().click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function picker() {
|
||||
return document.body.querySelector('[data-testid="column-picker"], [role="dialog"]')
|
||||
}
|
||||
|
||||
function pickerRows() {
|
||||
return [...document.body.querySelectorAll<HTMLElement>('[data-testid="column-row"]')]
|
||||
}
|
||||
|
||||
function checkbox(label: string) {
|
||||
const row = pickerRows().find((candidate) => text(candidate).startsWith(label))!
|
||||
return row.querySelector<HTMLElement>('[role="checkbox"]')!
|
||||
}
|
||||
|
||||
function checkedLabels() {
|
||||
return pickerRows()
|
||||
.filter(
|
||||
(row) =>
|
||||
row.querySelector('[role="checkbox"]')?.getAttribute('aria-checked') === 'true',
|
||||
)
|
||||
.map((row) =>
|
||||
text(row).split(' ')[0] === 'Starts' ? 'Starts on' : text(row).split(' ')[0]!,
|
||||
)
|
||||
}
|
||||
|
||||
function isDisabled(label: string) {
|
||||
return (
|
||||
checkbox(label).hasAttribute('disabled') ||
|
||||
checkbox(label).getAttribute('data-disabled') !== null
|
||||
)
|
||||
}
|
||||
|
||||
async function toggle(label: string) {
|
||||
checkbox(label).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function stored() {
|
||||
return localStorage.getItem(KEY)
|
||||
}
|
||||
|
||||
function button(label: string) {
|
||||
return [...document.body.querySelectorAll('button')].find(
|
||||
(candidate) => text(candidate) === label,
|
||||
)
|
||||
}
|
||||
|
||||
describe('3 Columns menu', () => {
|
||||
it('opens a popover titled Columns with 4 of 6, a Reset and no Done', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
await openPicker()
|
||||
|
||||
const popover = picker()!
|
||||
expect(popover.getAttribute('role')).toBe('dialog')
|
||||
expect(text(popover)).toContain('Columns')
|
||||
expect(text(popover)).toContain('4 of 6')
|
||||
expect(button('Reset')).toBeDefined()
|
||||
expect(button('Done')).toBeUndefined()
|
||||
expect(text(popover)).not.toContain('Choose up to 6 fields to show')
|
||||
})
|
||||
|
||||
it('lists the seven fields in schema order with icon, label and key, four checked', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
await openPicker()
|
||||
|
||||
expect(pickerRows().map(text)).toEqual([
|
||||
'Title title',
|
||||
'Starts on starts_on',
|
||||
'Ends on ends_on',
|
||||
'Online online',
|
||||
'Capacity capacity',
|
||||
'Category category',
|
||||
'Description description',
|
||||
])
|
||||
expect(
|
||||
pickerRows().map((row) =>
|
||||
row.querySelector('[role="checkbox"]')?.getAttribute('aria-checked'),
|
||||
),
|
||||
).toEqual(['true', 'true', 'true', 'true', 'false', 'false', 'false'])
|
||||
expect(pickerRows().every((row) => row.querySelector('svg') !== null)).toBe(true)
|
||||
})
|
||||
|
||||
it('ends with a disabled, unchecked clock row about the dates', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
await openPicker()
|
||||
|
||||
const row = document.body.querySelector('[data-testid="column-fixed-row"]')!
|
||||
expect(text(row)).toBe('Created and updated dates always show')
|
||||
const box = row.querySelector('[role="checkbox"]')!
|
||||
expect(box.getAttribute('aria-checked')).toBe('false')
|
||||
expect(box.hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('adds a right-aligned Capacity column at once, with no request, and counts 5 of 6', async () => {
|
||||
await mountList('/content/events')
|
||||
const before = listRequests(calls).length
|
||||
await openPicker()
|
||||
|
||||
await toggle('Capacity')
|
||||
|
||||
expect(headers()).toEqual([...DEFAULT_HEADERS, 'Capacity', ...TAIL_HEADERS])
|
||||
const capacity = [...document.body.querySelectorAll('thead th')][5]!
|
||||
expect(capacity.querySelector('span')?.className).toContain('justify-end')
|
||||
expect(document.body.querySelector('tbody tr td:nth-child(6) span')?.className).toContain(
|
||||
'text-right',
|
||||
)
|
||||
expect(text(picker())).toContain('5 of 6')
|
||||
expect(listRequests(calls)).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('removes a column when it is unchecked', async () => {
|
||||
await mountList('/content/events')
|
||||
await openPicker()
|
||||
|
||||
await toggle('Online')
|
||||
|
||||
expect(headers()).toEqual(['ID', 'Title', 'Starts on', 'Ends on', ...TAIL_HEADERS])
|
||||
expect(text(picker())).toContain('3 of 6')
|
||||
})
|
||||
|
||||
it('closes on Escape and returns focus to Columns', async () => {
|
||||
await mountList('/content/events')
|
||||
await openPicker()
|
||||
|
||||
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
picker()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[data-testid="column-picker"]')).toBeNull()
|
||||
expect(document.activeElement).toBe(trigger())
|
||||
})
|
||||
|
||||
it('closes on a click outside', async () => {
|
||||
await mountList('/content/events')
|
||||
await openPicker()
|
||||
|
||||
const outside = document.querySelector('h1')!
|
||||
outside.dispatchEvent(
|
||||
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
|
||||
pointerType: 'mouse',
|
||||
}),
|
||||
)
|
||||
outside.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[data-testid="column-picker"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('3 Columns menu on mobile', () => {
|
||||
it('opens a sheet with the sentence, Reset and Done, and Done closes it', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
await openPicker()
|
||||
|
||||
const sheet = document.body.querySelector('[role="dialog"]')!
|
||||
expect(text(sheet)).toContain('Columns')
|
||||
expect(text(sheet)).toContain('4 of 6')
|
||||
expect(text(sheet)).toContain('Choose up to 6 fields to show. 4 selected.')
|
||||
expect(button('Reset')).toBeDefined()
|
||||
expect(button('Done')).toBeDefined()
|
||||
expect(pickerRows()).toHaveLength(7)
|
||||
|
||||
button('Done')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
expect(document.activeElement).toBe(trigger())
|
||||
})
|
||||
|
||||
it('counts the selection in the sentence', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
await openPicker()
|
||||
|
||||
await toggle('Capacity')
|
||||
|
||||
expect(text(document.body.querySelector('[role="dialog"]'))).toContain(
|
||||
'Choose up to 6 fields to show. 5 selected.',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the cards unchanged when the selection changes', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
const before = [...document.body.querySelectorAll('[data-testid="entry-card"]')].map(text)
|
||||
await openPicker()
|
||||
|
||||
await toggle('Capacity')
|
||||
await toggle('Title')
|
||||
|
||||
expect([...document.body.querySelectorAll('[data-testid="entry-card"]')].map(text)).toEqual(
|
||||
before,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The column cap', () => {
|
||||
const SIX = JSON.stringify(ALL_KEYS.slice(0, 6))
|
||||
|
||||
it('disables the unchecked fields and explains the cap at six', async () => {
|
||||
await mountList('/content/events', { before: seed(SIX) })
|
||||
|
||||
await openPicker()
|
||||
|
||||
expect(isDisabled('Description')).toBe(true)
|
||||
expect(isDisabled('Title')).toBe(false)
|
||||
expect(text(picker())).toContain('You can show up to 6 fields.')
|
||||
expect(text(picker())).toContain('6 of 6')
|
||||
})
|
||||
|
||||
it('shows no cap message below six', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
await openPicker()
|
||||
|
||||
expect(text(picker())).not.toContain('You can show up to 6 fields.')
|
||||
expect(isDisabled('Description')).toBe(false)
|
||||
})
|
||||
|
||||
it('checks a sixth and disables the others', async () => {
|
||||
await mountList('/content/events', { before: seed(JSON.stringify(ALL_KEYS.slice(0, 5))) })
|
||||
await openPicker()
|
||||
|
||||
await toggle('Description')
|
||||
|
||||
expect(checkedLabels()).toHaveLength(6)
|
||||
expect(isDisabled('Category')).toBe(true)
|
||||
expect(isDisabled('Title')).toBe(false)
|
||||
expect(text(picker())).toContain('6 of 6')
|
||||
})
|
||||
|
||||
it('enables the others again when one is unchecked', async () => {
|
||||
await mountList('/content/events', { before: seed(SIX) })
|
||||
await openPicker()
|
||||
|
||||
await toggle('Title')
|
||||
|
||||
expect(isDisabled('Description')).toBe(false)
|
||||
expect(text(picker())).not.toContain('You can show up to 6 fields.')
|
||||
})
|
||||
|
||||
it('shows both field columns and still reads the cap for a type with two fields', async () => {
|
||||
await mountList('/content/events', {
|
||||
type: () => ({ ...customTypes[0]!, fields: eventsFields.slice(0, 2) }),
|
||||
})
|
||||
|
||||
expect(headers()).toEqual(['ID', 'Title', 'Starts on', ...TAIL_HEADERS])
|
||||
|
||||
await openPicker()
|
||||
|
||||
expect(text(picker())).toContain('2 of 6')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The last field column cannot be unchecked', () => {
|
||||
it('disables the only checked field and keeps its column', async () => {
|
||||
await mountList('/content/events', { before: seed('["capacity"]') })
|
||||
await openPicker()
|
||||
|
||||
expect(isDisabled('Capacity')).toBe(true)
|
||||
expect(isDisabled('Title')).toBe(false)
|
||||
|
||||
await toggle('Capacity')
|
||||
|
||||
expect(headers()).toEqual(['ID', 'Capacity', ...TAIL_HEADERS])
|
||||
expect(text(picker())).toContain('1 of 6')
|
||||
expect(document.body.querySelectorAll('[data-testid="entry-link"]')).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('unlocks it again once a second field is checked', async () => {
|
||||
await mountList('/content/events', { before: seed('["capacity"]') })
|
||||
await openPicker()
|
||||
|
||||
await toggle('Title')
|
||||
|
||||
expect(isDisabled('Capacity')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The stored choice', () => {
|
||||
it('writes the default four plus Capacity when Capacity is checked and nothing was stored', async () => {
|
||||
await mountList('/content/events')
|
||||
expect(stored()).toBeNull()
|
||||
await openPicker()
|
||||
|
||||
await toggle('Capacity')
|
||||
|
||||
expect(stored()).toBe('["title","starts_on","ends_on","online","capacity"]')
|
||||
})
|
||||
|
||||
it('removes the stored key and applies the default four on Reset', async () => {
|
||||
await mountList('/content/events', { before: seed('["title","capacity"]') })
|
||||
expect(headers()).toEqual(['ID', 'Title', 'Capacity', ...TAIL_HEADERS])
|
||||
await openPicker()
|
||||
|
||||
button('Reset')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(headers()).toEqual([...DEFAULT_HEADERS, ...TAIL_HEADERS])
|
||||
expect(stored()).toBeNull()
|
||||
})
|
||||
|
||||
it('drops unknown keys on load and writes nothing', async () => {
|
||||
let setItem!: ReturnType<typeof vi.spyOn>
|
||||
let removeItem!: ReturnType<typeof vi.spyOn>
|
||||
await mountList('/content/events', {
|
||||
before: () => {
|
||||
seed('["title","gone","capacity"]')()
|
||||
setItem = vi.spyOn(Storage.prototype, 'setItem')
|
||||
removeItem = vi.spyOn(Storage.prototype, 'removeItem')
|
||||
},
|
||||
})
|
||||
|
||||
expect(headers()).toEqual(['ID', 'Title', 'Capacity', ...TAIL_HEADERS])
|
||||
expect(stored()).toBe('["title","gone","capacity"]')
|
||||
expect(setItem.mock.calls.filter(([key]) => key === KEY)).toEqual([])
|
||||
expect(removeItem.mock.calls.filter(([key]) => key === KEY)).toEqual([])
|
||||
})
|
||||
|
||||
it('shows the first six in schema order for more stored keys than the cap', async () => {
|
||||
await mountList('/content/events', {
|
||||
before: seed(JSON.stringify([...ALL_KEYS, 'title'])),
|
||||
})
|
||||
|
||||
expect(headers()).toEqual([
|
||||
'ID',
|
||||
'Title',
|
||||
'Starts on',
|
||||
'Ends on',
|
||||
'Online',
|
||||
'Capacity',
|
||||
'Category',
|
||||
...TAIL_HEADERS,
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['not json', '[]', '{"title":true}', '"title"', 'null'])(
|
||||
'shows the default four for the stored value %s',
|
||||
async (raw) => {
|
||||
await mountList('/content/events', { before: seed(raw) })
|
||||
|
||||
expect(headers()).toEqual([...DEFAULT_HEADERS, ...TAIL_HEADERS])
|
||||
expect(stored()).toBe(raw)
|
||||
},
|
||||
)
|
||||
|
||||
it('changes the columns for this visit when localStorage throws, with no error', async () => {
|
||||
await mountList('/content/events')
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota')
|
||||
})
|
||||
await openPicker()
|
||||
|
||||
await toggle('Capacity')
|
||||
|
||||
expect(headers()).toEqual([...DEFAULT_HEADERS, 'Capacity', ...TAIL_HEADERS])
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('reads and changes columns when localStorage is undefined', async () => {
|
||||
await mountList('/content/events', {
|
||||
before: () => vi.stubGlobal('localStorage', undefined),
|
||||
})
|
||||
expect(headers()).toEqual([...DEFAULT_HEADERS, ...TAIL_HEADERS])
|
||||
await openPicker()
|
||||
|
||||
await toggle('Capacity')
|
||||
|
||||
expect(headers()).toEqual([...DEFAULT_HEADERS, 'Capacity', ...TAIL_HEADERS])
|
||||
})
|
||||
|
||||
it('gives Recipes its own key and its default when only Events has a stored choice', async () => {
|
||||
await mountList('/content/recipes', {
|
||||
before: seed('["title","capacity"]'),
|
||||
list: (_, query) => listPage(eventEntries, 7, query),
|
||||
})
|
||||
|
||||
expect(headers()).toEqual([
|
||||
'ID',
|
||||
'Title',
|
||||
'Servings',
|
||||
'Vegetarian',
|
||||
'Method',
|
||||
...TAIL_HEADERS,
|
||||
])
|
||||
})
|
||||
|
||||
it('applies the Events choice again after switching from Recipes to Events', async () => {
|
||||
await mountList('/content/recipes', {
|
||||
before: seed('["title","capacity"]'),
|
||||
list: (_, query) => listPage(eventEntries, 7, query),
|
||||
})
|
||||
await router.push('/content/events')
|
||||
await flushPromises()
|
||||
|
||||
expect(headers()).toEqual(['ID', 'Title', 'Capacity', ...TAIL_HEADERS])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { eventsFields } from '@/lib/__tests__/schemaFixtures'
|
||||
import {
|
||||
SKELETON_DELAY_MS,
|
||||
button,
|
||||
customTypes,
|
||||
eventEntries,
|
||||
failWith,
|
||||
installSelectPolyfills,
|
||||
listPage,
|
||||
mountList,
|
||||
pending,
|
||||
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)
|
||||
|
||||
function main() {
|
||||
return document.body.querySelector('#dashboard-content')!
|
||||
}
|
||||
|
||||
function link(label: string) {
|
||||
return [...main().querySelectorAll<HTMLAnchorElement>('a')].find(
|
||||
(candidate) => text(candidate) === label,
|
||||
)
|
||||
}
|
||||
|
||||
function cards() {
|
||||
return [...main().querySelectorAll<HTMLAnchorElement>('[data-testid="entry-card"]')]
|
||||
}
|
||||
|
||||
function floatingButton() {
|
||||
return [...main().querySelectorAll('button')].find(
|
||||
(candidate) => text(candidate) === 'New entry' && candidate.className.includes('fixed'),
|
||||
)
|
||||
}
|
||||
|
||||
async function arrived(path: string) {
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe(path))
|
||||
}
|
||||
|
||||
describe('Navigation actions', () => {
|
||||
it('sends New entry to /content/events/new', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
button('New entry')!.click()
|
||||
|
||||
await arrived('/content/events/new')
|
||||
})
|
||||
|
||||
it('links the first cell of every row to /content/events/:id and navigates through the router', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
const links = [...main().querySelectorAll<HTMLAnchorElement>('[data-testid="entry-link"]')]
|
||||
expect(links.map((anchor) => anchor.getAttribute('href'))).toEqual(
|
||||
eventEntries.map((entry) => `/content/events/${entry.id}`),
|
||||
)
|
||||
expect(text(links[0])).toBe('Lime plaster workshop')
|
||||
|
||||
links[0]!.click()
|
||||
|
||||
await arrived('/content/events/42')
|
||||
})
|
||||
|
||||
it('links the ID cell when no field column is chosen', async () => {
|
||||
await mountList('/content/events', {
|
||||
before: () => localStorage.setItem('renewcms-content-columns-events', '["gone"]'),
|
||||
})
|
||||
|
||||
expect(main().querySelectorAll('[data-testid="entry-link"]')).toHaveLength(7)
|
||||
})
|
||||
|
||||
it.each(['owner', 'admin'])(
|
||||
'sends Edit content type to /schema/events for a %s',
|
||||
async (role) => {
|
||||
await mountList('/content/events', { role })
|
||||
|
||||
const edit = link('Edit content type')!
|
||||
expect(edit.getAttribute('href')).toBe('/schema/events')
|
||||
|
||||
edit.click()
|
||||
|
||||
await arrived('/schema/events')
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['user', 'editor', 'unknown'])(
|
||||
'does not render Edit content type for the role %s',
|
||||
async (role) => {
|
||||
await mountList('/content/events', { role })
|
||||
|
||||
expect(link('Edit content type')).toBeUndefined()
|
||||
expect(main().querySelector('[data-testid="entry-toolbar"]')).not.toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('does not render Edit content type below md', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(link('Edit content type')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('puts the toolbar in the order select, direction, Columns, Edit', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
const toolbar = main().querySelector('[data-testid="entry-toolbar"]')!
|
||||
const order = [...toolbar.querySelectorAll('button, a')].map(
|
||||
(element) => element.getAttribute('aria-label') ?? text(element),
|
||||
)
|
||||
expect(order).toEqual(['Sort by', 'Sort ascending', 'Columns', 'Edit content type'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mobile cards', () => {
|
||||
it('shows a card per entry with the first field, the second field, the tag and the author', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(main().querySelector('table')).toBeNull()
|
||||
expect(cards()).toHaveLength(7)
|
||||
expect(text(cards()[0])).toBe('Lime plaster workshop 21 Nov 2026 Workshop · Marta Alves')
|
||||
expect(cards()[0]!.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('links the whole card to /content/events/:id through the router', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(cards()[1]!.getAttribute('href')).toBe('/content/events/41')
|
||||
|
||||
cards()[1]!.click()
|
||||
|
||||
await arrived('/content/events/41')
|
||||
})
|
||||
|
||||
it('shows a muted dash for the author until the lookup resolves', async () => {
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH, lookup: () => gate.promise })
|
||||
|
||||
expect(text(cards()[0])).toBe('Lime plaster workshop 21 Nov 2026 Workshop · –')
|
||||
})
|
||||
|
||||
it('shows no tag for a type without a choice field and no tag when the choice is empty', async () => {
|
||||
await mountList('/content/events', {
|
||||
width: MOBILE_WIDTH,
|
||||
type: () => ({ ...customTypes[0]!, fields: eventsFields.slice(0, 3) }),
|
||||
})
|
||||
|
||||
expect(text(cards()[0])).toBe('Lime plaster workshop 21 Nov 2026 · Marta Alves')
|
||||
})
|
||||
|
||||
it('shows the choice once, as the tag, when the second field is the choice', async () => {
|
||||
const [title, , , , , category] = eventsFields
|
||||
await mountList('/content/events', {
|
||||
width: MOBILE_WIDTH,
|
||||
type: () => ({ ...customTypes[0]!, fields: [title, category] }),
|
||||
})
|
||||
|
||||
expect(text(cards()[0])).toBe('Lime plaster workshop Workshop · Marta Alves')
|
||||
})
|
||||
|
||||
it('shows a floating New entry button that goes to /content/events/new', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(button('New entry')).toBe(floatingButton())
|
||||
|
||||
floatingButton()!.click()
|
||||
|
||||
await arrived('/content/events/new')
|
||||
})
|
||||
|
||||
it('shows the floating button on the empty state', async () => {
|
||||
await mountList('/content/events', {
|
||||
width: MOBILE_WIDTH,
|
||||
list: (_, query) => listPage([], 0, query),
|
||||
})
|
||||
|
||||
expect(floatingButton()).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides the floating button while loading', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH, list: () => gate.promise })
|
||||
vi.advanceTimersByTime(SKELETON_DELAY_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(floatingButton()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hides the floating button in the error state', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH, list: failWith(500) })
|
||||
|
||||
expect(floatingButton()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows no New entry in the title row on mobile besides the floating one', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(
|
||||
[...main().querySelectorAll('button')].filter(
|
||||
(candidate) => text(candidate) === 'New entry',
|
||||
),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it.each([
|
||||
['/content/events', 'Events, sorted by last updated, newest first'],
|
||||
['/content/events?sort=updated_at:asc', 'Events, sorted by updated, ascending'],
|
||||
['/content/events?sort=id:desc', 'Events, sorted by id, descending'],
|
||||
['/content/events?sort=created_at:desc', 'Events, sorted by created, descending'],
|
||||
])('captions the table for %s as "%s"', async (path, caption) => {
|
||||
await mountList(path)
|
||||
|
||||
expect(text(main().querySelector('table caption'))).toBe(caption)
|
||||
expect(main().querySelector('table caption')?.className).toContain('sr-only')
|
||||
})
|
||||
|
||||
it('names Columns, the direction button and the select', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
const toolbar = main().querySelector('[data-testid="entry-toolbar"]')!
|
||||
expect(toolbar.querySelector('[aria-label="Sort by"]')).not.toBeNull()
|
||||
expect(toolbar.querySelector('[aria-label="Sort ascending"]')).not.toBeNull()
|
||||
expect(button('Columns')).toBeDefined()
|
||||
})
|
||||
|
||||
it('names the icon-only Columns button on mobile', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(main().querySelector('button[aria-label="Columns"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('makes the controls 44 px high on mobile', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(main().querySelector('[aria-label="Sort by"]')?.className).toContain('h-11')
|
||||
expect(main().querySelector('[aria-label="Sort ascending"]')?.className).toContain(
|
||||
'size-11',
|
||||
)
|
||||
expect(main().querySelector('button[aria-label="Columns"]')?.className).toContain('h-11')
|
||||
})
|
||||
|
||||
it('moves focus into the sheet on mobile and keeps it there', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
main().querySelector<HTMLElement>('button[aria-label="Columns"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const sheet = document.body.querySelector('[role="dialog"]')!
|
||||
expect(sheet.contains(document.activeElement)).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles a field from its checkbox without closing the picker', async () => {
|
||||
await mountList('/content/events')
|
||||
button('Columns')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const box = document.body.querySelector<HTMLElement>(
|
||||
'[data-testid="column-row"] [role="checkbox"]',
|
||||
)!
|
||||
box.focus()
|
||||
box.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
|
||||
box.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true }))
|
||||
box.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(box.getAttribute('aria-checked')).toBe('false')
|
||||
expect(document.body.querySelector('[data-testid="column-picker"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('French', () => {
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
it('translates the toolbar, the headers, the count and the caption', async () => {
|
||||
setLocale('fr')
|
||||
await mountList('/content/events')
|
||||
|
||||
expect(text(main().querySelector('[data-testid="entry-toolbar"]'))).toBe(
|
||||
'Trier par Dernière mise à jour Colonnes Modifier le type de contenu',
|
||||
)
|
||||
expect([...main().querySelectorAll('thead th')].map(text).slice(-3)).toEqual([
|
||||
'Créé',
|
||||
'Modifié',
|
||||
'Modifié par',
|
||||
])
|
||||
expect(text(main().querySelector('[data-testid="paginator-count"]'))).toBe('7 entrées')
|
||||
expect(text(main().querySelector('table caption'))).toBe(
|
||||
'Events, trié par dernière modification, plus récent en premier',
|
||||
)
|
||||
expect(button('Nouvelle entrée')).toBeDefined()
|
||||
})
|
||||
|
||||
it('translates the empty state', async () => {
|
||||
setLocale('fr')
|
||||
await mountList('/content/events', { list: (_, query) => listPage([], 0, query) })
|
||||
|
||||
expect(text(main())).toContain('Aucune entrée pour le moment')
|
||||
expect(text(main())).toContain('ajouter la première entrée. Chaque entrée suit')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import router from '@/router'
|
||||
import {
|
||||
calls,
|
||||
chooseSize,
|
||||
chooseSort,
|
||||
eventEntries,
|
||||
installSelectPolyfills,
|
||||
listPage,
|
||||
listRequests,
|
||||
mountList,
|
||||
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)
|
||||
|
||||
function paged(_: string, query: URLSearchParams) {
|
||||
return listPage(eventEntries.slice(0, Number(query.get('size')) > 5 ? 7 : 5), 37, query)
|
||||
}
|
||||
|
||||
function iconPath(name: string) {
|
||||
return /d="([^"]+)"/.exec(lucide.icons[name]!.body)![1]
|
||||
}
|
||||
|
||||
function lastRequest() {
|
||||
return listRequests(calls).at(-1)
|
||||
}
|
||||
|
||||
function selectText() {
|
||||
return text(document.body.querySelector('[aria-label="Sort by"]'))
|
||||
}
|
||||
|
||||
function directionButton() {
|
||||
return document.body.querySelector<HTMLElement>(
|
||||
'[aria-label="Sort ascending"], [aria-label="Sort descending"]',
|
||||
)!
|
||||
}
|
||||
|
||||
function sortedHeader() {
|
||||
return [...document.body.querySelectorAll('thead th')].find((header) =>
|
||||
header.querySelector('[data-sort-direction]'),
|
||||
)
|
||||
}
|
||||
|
||||
describe('Sort control', () => {
|
||||
it.each([
|
||||
['ID', 'id:desc', '/content/events?sort=id:desc', 'ID'],
|
||||
['Created', 'created_at:desc', '/content/events?sort=created_at:desc', 'Created'],
|
||||
['Last updated', 'updated_at:desc', '/content/events', 'Updated'],
|
||||
])('choosing %s sends %s with page 1 and ends on %s', async (option, sort, url, header) => {
|
||||
await mountList('/content/events?page=3', { list: paged })
|
||||
|
||||
await chooseSort(option)
|
||||
|
||||
expect(lastRequest()).toBe(`/v1/content-type/events/entry?page=1&size=25&sort=${sort}`)
|
||||
expect(router.currentRoute.value.fullPath).toBe(url)
|
||||
expect(text(sortedHeader())).toBe(header)
|
||||
})
|
||||
|
||||
it('offers ID, Created and Last updated only', async () => {
|
||||
await mountList('/content/events', { list: paged })
|
||||
|
||||
expect(selectText()).toBe('Sort by Last updated')
|
||||
expect(document.body.querySelector('[aria-label="Sort by"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the direction when the field changes', async () => {
|
||||
await mountList('/content/events?sort=updated_at:asc', { list: paged })
|
||||
|
||||
await chooseSort('ID')
|
||||
|
||||
expect(lastRequest()).toContain('sort=id:asc')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events?sort=id:asc')
|
||||
})
|
||||
|
||||
it('changes the URL when the response succeeds, not when the request is sent', async () => {
|
||||
let release!: (value: unknown) => void
|
||||
const gate = new Promise((resolve) => (release = resolve))
|
||||
await mountList('/content/events', {
|
||||
list: (key, query) => (query.get('sort') === 'id:desc' ? gate : paged(key, query)),
|
||||
})
|
||||
|
||||
await chooseSort('ID')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events')
|
||||
|
||||
release(listPage(eventEntries, 7, new URLSearchParams('page=1&size=25&sort=id:desc')))
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events?sort=id:desc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Direction button', () => {
|
||||
it.each([
|
||||
['/content/events', 'updated_at:asc', '/content/events?sort=updated_at:asc'],
|
||||
['/content/events?sort=updated_at:asc', 'updated_at:desc', '/content/events'],
|
||||
['/content/events?sort=id:desc', 'id:asc', '/content/events?sort=id:asc'],
|
||||
])('from %s sends %s and ends on %s', async (from, sort, url) => {
|
||||
await mountList(from, { list: paged })
|
||||
|
||||
await press(directionButton().getAttribute('aria-label')!)
|
||||
|
||||
expect(lastRequest()).toBe(`/v1/content-type/events/entry?page=1&size=25&sort=${sort}`)
|
||||
expect(router.currentRoute.value.fullPath).toBe(url)
|
||||
})
|
||||
|
||||
it('goes back to page 1', async () => {
|
||||
await mountList('/content/events?size=5&page=3', { list: paged })
|
||||
|
||||
await press('Sort ascending')
|
||||
|
||||
expect(lastRequest()).toBe(
|
||||
'/v1/content-type/events/entry?page=1&size=5&sort=updated_at:asc',
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/content/events', 'Sort ascending', 'arrow-down-wide-narrow'],
|
||||
['/content/events?sort=updated_at:asc', 'Sort descending', 'arrow-up-narrow-wide'],
|
||||
])('on %s is named "%s" and draws %s', async (from, name, icon) => {
|
||||
await mountList(from, { list: paged })
|
||||
|
||||
const button = directionButton()
|
||||
expect(button.getAttribute('aria-label')).toBe(name)
|
||||
expect(button.querySelector('svg path')?.getAttribute('d')).toBe(iconPath(icon))
|
||||
})
|
||||
|
||||
it('shows the arrow of the direction on the sorted header', async () => {
|
||||
await mountList('/content/events?sort=created_at:asc', { list: paged })
|
||||
|
||||
expect(sortedHeader()?.querySelector('[data-sort-direction="asc"]')).not.toBeNull()
|
||||
expect(text(sortedHeader())).toBe('Created')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sort values in the URL', () => {
|
||||
it('reads created_at:asc into the select and the direction icon', async () => {
|
||||
await mountList('/content/events?sort=created_at:asc', { list: paged })
|
||||
|
||||
expect(lastRequest()).toContain('sort=created_at:asc')
|
||||
expect(selectText()).toBe('Sort by Created')
|
||||
expect(directionButton().getAttribute('aria-label')).toBe('Sort descending')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events?sort=created_at:asc')
|
||||
})
|
||||
|
||||
it.each(['title:asc', 'data:asc', 'id', 'id:sideways', 'UPDATED_AT:DESC'])(
|
||||
'replaces sort=%s by the default in the request and the URL',
|
||||
async (value) => {
|
||||
await mountList(`/content/events?sort=${value}`, { list: paged })
|
||||
|
||||
expect(lastRequest()).toContain('sort=updated_at:desc')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events')
|
||||
expect(selectText()).toBe('Sort by Last updated')
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
['page=abc', 'page=1&size=25'],
|
||||
['page=0', 'page=1&size=25'],
|
||||
['size=10', 'page=1&size=25'],
|
||||
['size=100&page=2', 'page=2&size=100'],
|
||||
])('reads ?%s as %s and cleans the URL when needed', async (query, requested) => {
|
||||
await mountList(`/content/events?${query}`, { list: paged })
|
||||
|
||||
expect(lastRequest()).toContain(requested)
|
||||
})
|
||||
|
||||
it('replaces an invalid size and page in the URL', async () => {
|
||||
await mountList('/content/events?size=10&page=abc', { list: paged })
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sort is kept across paging and size changes', () => {
|
||||
it('carries sort=id:asc on every request and keeps it in the URL', async () => {
|
||||
await mountList('/content/events?sort=id:asc&size=5', { list: paged })
|
||||
|
||||
await press('Next page')
|
||||
|
||||
expect(lastRequest()).toBe('/v1/content-type/events/entry?page=2&size=5&sort=id:asc')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&sort=id:asc&page=2')
|
||||
|
||||
await chooseSize('50 per page')
|
||||
|
||||
expect(lastRequest()).toBe('/v1/content-type/events/entry?page=1&size=50&sort=id:asc')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=50&sort=id:asc')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,250 @@
|
||||
import { vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { mountAt, type SchemaMount } from './schemaTestKit'
|
||||
|
||||
export {
|
||||
button,
|
||||
calls,
|
||||
customTypes,
|
||||
failWith,
|
||||
failWithBody,
|
||||
manyTypes,
|
||||
networkFailure,
|
||||
pending,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './schemaTestKit'
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
export const SKELETON_DELAY_MS = 150
|
||||
export const DEFAULT_SORT = 'updated_at:desc'
|
||||
|
||||
const MARTA = 3
|
||||
const INES = 4
|
||||
const JONAS = 5
|
||||
|
||||
type Row = [
|
||||
number,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
boolean,
|
||||
number | null,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
]
|
||||
|
||||
const ROWS: Row[] = [
|
||||
[
|
||||
42,
|
||||
'Lime plaster workshop',
|
||||
'2026-11-21',
|
||||
'2026-11-22',
|
||||
false,
|
||||
18,
|
||||
'Workshop',
|
||||
'Two days of hands-on work with lime putty and old stone.',
|
||||
'2026-10-12',
|
||||
'2026-10-15',
|
||||
MARTA,
|
||||
],
|
||||
[
|
||||
41,
|
||||
'Heat pumps: what the numbers say',
|
||||
'2026-11-03',
|
||||
'',
|
||||
true,
|
||||
500,
|
||||
'Talk',
|
||||
'A look at running costs after two winters.',
|
||||
'2026-10-08',
|
||||
'2026-10-14',
|
||||
JONAS,
|
||||
],
|
||||
[
|
||||
40,
|
||||
'Open day at the Rue Verte site',
|
||||
'2026-11-12',
|
||||
'2026-11-12',
|
||||
false,
|
||||
120,
|
||||
'Meetup',
|
||||
'',
|
||||
'2026-10-02',
|
||||
'2026-10-12',
|
||||
INES,
|
||||
],
|
||||
[
|
||||
39,
|
||||
'Winter windows clinic',
|
||||
'2026-12-05',
|
||||
'',
|
||||
false,
|
||||
null,
|
||||
'Workshop',
|
||||
'Bring a window, leave with a plan.',
|
||||
'2026-09-30',
|
||||
'2026-10-09',
|
||||
MARTA,
|
||||
],
|
||||
[
|
||||
38,
|
||||
'Timber joinery for beginners',
|
||||
'2027-01-14',
|
||||
'2027-01-15',
|
||||
false,
|
||||
12,
|
||||
'Workshop',
|
||||
'Mortise, tenon and a lot of patience.',
|
||||
'2026-09-28',
|
||||
'2026-10-07',
|
||||
INES,
|
||||
],
|
||||
[
|
||||
37,
|
||||
'Q&A with the site managers',
|
||||
'2026-10-28',
|
||||
'',
|
||||
true,
|
||||
null,
|
||||
'Meetup',
|
||||
'',
|
||||
'2026-09-21',
|
||||
'2026-10-02',
|
||||
JONAS,
|
||||
],
|
||||
[
|
||||
36,
|
||||
'Reclaimed floorboards talk',
|
||||
'2026-10-17',
|
||||
'',
|
||||
false,
|
||||
60,
|
||||
'Talk',
|
||||
'Where to find them and how to lay them.',
|
||||
'2026-09-14',
|
||||
'2026-09-29',
|
||||
MARTA,
|
||||
],
|
||||
]
|
||||
|
||||
export function entry(row: Row) {
|
||||
const [
|
||||
id,
|
||||
title,
|
||||
startsOn,
|
||||
endsOn,
|
||||
online,
|
||||
capacity,
|
||||
category,
|
||||
description,
|
||||
created,
|
||||
updated,
|
||||
by,
|
||||
] = row
|
||||
return {
|
||||
id,
|
||||
content_type_id: 1,
|
||||
data: {
|
||||
title,
|
||||
starts_on: startsOn,
|
||||
ends_on: endsOn,
|
||||
online,
|
||||
capacity,
|
||||
category,
|
||||
description,
|
||||
},
|
||||
created_by: by,
|
||||
updated_by: by,
|
||||
created_at: `${created}T09:14:00Z`,
|
||||
updated_at: `${updated}T11:02:00Z`,
|
||||
}
|
||||
}
|
||||
|
||||
export const eventEntries = ROWS.map(entry)
|
||||
|
||||
export const userNames = [
|
||||
{ id: MARTA, display_name: 'Marta Alves' },
|
||||
{ id: INES, display_name: 'Inès Duarte' },
|
||||
{ id: JONAS, display_name: 'Jonas Pereira' },
|
||||
]
|
||||
|
||||
export function listPage(items: unknown[], total: number, query: URLSearchParams) {
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: Number(query.get('page')),
|
||||
size: Number(query.get('size')),
|
||||
sort: query.get('sort'),
|
||||
}
|
||||
}
|
||||
|
||||
export function populated(key: string, query: URLSearchParams) {
|
||||
return key === 'events' ? listPage(eventEntries, 7, query) : listPage([], 0, query)
|
||||
}
|
||||
|
||||
export function lookupNames(ids: number[]) {
|
||||
return userNames.filter((user) => ids.includes(user.id))
|
||||
}
|
||||
|
||||
export function mountList(path: string, options: SchemaMount = {}) {
|
||||
return mountAt(path, { list: populated, lookup: lookupNames, ...options })
|
||||
}
|
||||
|
||||
export async function settle() {
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function listRequests(calls: string[]) {
|
||||
return calls.filter((url) => /\/entry\?/.test(url) && !/size=5$/.test(url))
|
||||
}
|
||||
|
||||
export function lookupRequests(calls: string[]) {
|
||||
return calls.filter((url) => url.startsWith('/v1/user/lookup'))
|
||||
}
|
||||
|
||||
export function installSelectPolyfills() {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
Element.prototype.hasPointerCapture = vi.fn(() => false)
|
||||
Element.prototype.releasePointerCapture = vi.fn()
|
||||
}
|
||||
|
||||
export function openSelect(trigger: Element | null) {
|
||||
trigger?.dispatchEvent(
|
||||
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
|
||||
pointerType: 'mouse',
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function chooseSort(label: string) {
|
||||
openSelect(document.body.querySelector('[aria-label="Sort by"]'))
|
||||
await flushPromises()
|
||||
const option = [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].find(
|
||||
(candidate) => candidate.textContent?.trim() === label,
|
||||
)!
|
||||
option.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||||
option.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function chooseSize(label: string) {
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')!.click()
|
||||
await flushPromises()
|
||||
const option = [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].find(
|
||||
(candidate) => candidate.textContent?.trim() === label,
|
||||
)!
|
||||
option.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function press(label: string) {
|
||||
document.body.querySelector<HTMLElement>(`[aria-label="${label}"]`)!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
@@ -126,6 +126,14 @@ export function entryPage(total: number) {
|
||||
return { items: [], total, page: 1, size: 1 }
|
||||
}
|
||||
|
||||
function defaultType(key: string) {
|
||||
const found = customTypes.find((type) => type.key === key)
|
||||
if (!found) {
|
||||
throw new ApiError(404, undefined, 'content_type_not_found')
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
export interface SchemaMount {
|
||||
role?: string
|
||||
create?: (body: PostedRequest['body']) => unknown
|
||||
@@ -137,6 +145,10 @@ export interface SchemaMount {
|
||||
builtin?: Responder
|
||||
verified?: boolean
|
||||
actions?: string[]
|
||||
type?: (key: string) => unknown
|
||||
list?: (key: string, query: URLSearchParams) => unknown
|
||||
lookup?: (ids: number[]) => unknown
|
||||
before?: () => void
|
||||
}
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
@@ -156,6 +168,7 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
puts.length = 0
|
||||
deletes.length = 0
|
||||
localStorage.clear()
|
||||
options.before?.()
|
||||
setViewportWidth(options.width ?? DESKTOP_WIDTH)
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
@@ -199,6 +212,21 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
if (counted) {
|
||||
return (options.entries ?? ((key) => entryPage(entryCounts[key] ?? 0)))(counted[1]!)
|
||||
}
|
||||
const listed = url.match(/^\/v1\/content-type\/([^/?]+)\/entry\?(.*)$/)
|
||||
if (listed) {
|
||||
return (options.list ?? (() => entryPage(0)))(
|
||||
listed[1]!,
|
||||
new URLSearchParams(listed[2]),
|
||||
)
|
||||
}
|
||||
const looked = url.match(/^\/v1\/user\/lookup\?ids=(.*)$/)
|
||||
if (looked) {
|
||||
return (options.lookup ?? (() => []))(looked[1]!.split(',').map(Number))
|
||||
}
|
||||
const detailed = url.match(/^\/v1\/content-type\/([^/?]+)$/)
|
||||
if (detailed) {
|
||||
return (options.type ?? defaultType)(detailed[1]!)
|
||||
}
|
||||
if (url === '/v1/builtin-type') {
|
||||
return (options.builtin ?? (() => builtinTypes))()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user