feat: entry list at /content/:key

This commit is contained in:
Florian Sylvain committed 2026-10-20 21:05:03 +02:00
1 parent b4cbcb0395
commit 91d916fb74
44 files changed
+4984 -32

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import ContentEntries from '@/components/ContentEntries.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import { useContentTypeDetail } from '@/composables/useContentTypeDetail'
import type { ContentListState } from '@/lib/contentListState'
import { useSessionStore } from '@/stores/session'
const route = useRoute()
const session = useSessionStore()
const key = computed(() => String(route.params.key))
const detail = useContentTypeDetail(() => key.value)
const listView = ref<ContentListState>('loading')
watch(key, () => {
listView.value = 'loading'
})
const notFound = computed(() => detail.status.value === 'missing' || listView.value === 'notFound')
const centered = computed(
() => notFound.value || detail.status.value === 'forbidden' || listView.value === 'forbidden',
)
const displayName = computed(() =>
notFound.value ? undefined : (detail.type.value?.name ?? key.value),
)
const mobileTitle = computed(() => displayName.value ?? t('Content'))
const breadcrumb = computed(() => [
{ label: t('Content') },
...(displayName.value ? [{ label: displayName.value }] : []),
])
</script>
<template>
<DashboardShell
compact-padding
:full-bleed="centered"
:mobile-title="mobileTitle"
:no-current-nav="notFound"
>
<template #breadcrumb>
<UBreadcrumb
:items="breadcrumb"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<ContentEntries
v-if="session.status === 'ready'"
:key="key"
:type-key="key"
:type="detail.type.value"
:type-status="detail.status.value"
:can-edit="session.canManageContent"
@view="listView = $event"
@retry-type="detail.retry"
/>
</DashboardShell>
</template>
+640
View File
@@ -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')
})
})
+250
View File
@@ -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()
}
+28
View File
@@ -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))()
}