feat: built-in type read-only view,s chema list, states, spec files
This commit is contained in:
1 parent
3c67cd2314
commit
f514146fed
24 files changed
+1220
-88
No files matched your search
@@ -0,0 +1,265 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import router from '@/router'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
builtinTypes,
|
||||
button,
|
||||
calls,
|
||||
failWith,
|
||||
mountAt,
|
||||
networkFailure,
|
||||
pending,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './schemaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const ACCESS_TITLE = "You don't have access to Schema"
|
||||
const NOT_FOUND_TITLE = 'Content type not found'
|
||||
const NOT_FOUND_TEXT = 'It may have been deleted, or the address may be mistyped.'
|
||||
const NOTE =
|
||||
'Entries of built-in types are managed from their own screens, like Articles and Authors.'
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
function main() {
|
||||
return document.body.querySelector('#dashboard-content')!
|
||||
}
|
||||
|
||||
function fieldRows() {
|
||||
return [...document.body.querySelectorAll('[data-testid="schema-field-row"]')].map(text)
|
||||
}
|
||||
|
||||
describe('Schema list: built-in rows are links', () => {
|
||||
it('links each built-in row to its view, named after the type, with a chevron', async () => {
|
||||
await mountAt('/schema')
|
||||
|
||||
const links = [...document.body.querySelectorAll('a[data-testid="schema-row"]')]
|
||||
expect(links.map((link) => link.getAttribute('href'))).toEqual(
|
||||
builtinTypes.map((type) => `/schema/${type.key}`),
|
||||
)
|
||||
expect(links.map((link) => link.getAttribute('aria-label'))).toEqual(
|
||||
builtinTypes.map((type) => type.name),
|
||||
)
|
||||
expect(links.every((link) => link.querySelectorAll('svg').length === 3)).toBe(true)
|
||||
const rows = [...document.body.querySelectorAll('[data-testid="schema-row"]')]
|
||||
expect(rows.slice(0, 3).every((row) => row.tagName !== 'A')).toBe(true)
|
||||
expect(
|
||||
rows.slice(0, 3).every((row) => row.querySelector('[class*="chevron-right"]') === null),
|
||||
).toBe(true)
|
||||
expect(text(links[0])).toContain('Managed by RenewCMS')
|
||||
})
|
||||
|
||||
it('shows built-in cards as links with a chevron and no Updated text on mobile', async () => {
|
||||
await mountAt('/schema', { width: MOBILE_WIDTH })
|
||||
|
||||
const cards = [...document.body.querySelectorAll('[data-testid="schema-card"]')]
|
||||
const links = cards.filter((card) => card.tagName === 'A')
|
||||
expect(links).toHaveLength(5)
|
||||
expect(text(links[0])).toBe('Articles Built-in articles · 5 fields')
|
||||
expect(links[0]!.getAttribute('href')).toBe('/schema/articles')
|
||||
expect(links[0]!.querySelector('svg:last-of-type')).not.toBeNull()
|
||||
expect(cards.slice(0, 3).every((card) => card.tagName !== 'A')).toBe(true)
|
||||
})
|
||||
|
||||
it('opens the type without loading the built-in types a second time', async () => {
|
||||
await mountAt('/schema')
|
||||
;(document.body.querySelector('a[data-testid="schema-row"]') as HTMLElement).click()
|
||||
await flushPromises()
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/articles'))
|
||||
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema built-in view: read-only', () => {
|
||||
it('shows the heading, the alert, the field table and the note', async () => {
|
||||
await mountAt('/schema/articles')
|
||||
|
||||
expect(main().querySelector('h1')?.textContent).toBe('Articles')
|
||||
expect(text(main())).toContain('Built-in')
|
||||
expect(text(main())).toContain(
|
||||
"Managed by RenewCMS Articles is a built-in type. You can read its fields here, but you can't edit it, delete it or add fields.",
|
||||
)
|
||||
expect([...document.body.querySelectorAll('[role="columnheader"]')].map(text)).toEqual([
|
||||
'Label',
|
||||
'Key',
|
||||
'Type',
|
||||
'Rules',
|
||||
])
|
||||
expect(fieldRows()).toEqual([
|
||||
'Title title Text Required · up to 200 characters',
|
||||
'Slug slug Text Required · unique',
|
||||
'Body body Rich text Up to 10,000 characters',
|
||||
'Online online Yes or no Off by default',
|
||||
'Published on published_on Date –',
|
||||
])
|
||||
expect(text(main())).toContain(NOTE)
|
||||
const rowIcons = [
|
||||
...document.body.querySelectorAll('[data-testid="schema-field-row"]'),
|
||||
].map((row) => row.querySelector('svg')!.innerHTML)
|
||||
const expectedIcons = ['type', 'type', 'pilcrow', 'toggle-left', 'calendar'].map((name) => {
|
||||
const holder = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
holder.innerHTML = (lucide.icons[name] as { body: string }).body
|
||||
return holder.innerHTML
|
||||
})
|
||||
expect(rowIcons).toEqual(expectedIcons)
|
||||
expect(new Set(expectedIcons).size).toBe(4)
|
||||
expect(button('New content type')).toBeUndefined()
|
||||
expect(document.body.querySelector('input, textarea')).toBeNull()
|
||||
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(1)
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('has the Schema breadcrumb linking back to the list, then the type name', async () => {
|
||||
await mountAt('/schema/articles')
|
||||
|
||||
const crumbs = document.body.querySelector('nav[aria-label="breadcrumb"]')!
|
||||
expect(text(crumbs)).toBe('Schema Articles')
|
||||
const link = crumbs.querySelector('a')!
|
||||
expect(link.getAttribute('href')).toBe('/schema')
|
||||
link.click()
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/schema')
|
||||
})
|
||||
|
||||
it('shows cards without the Type column on mobile, with an arrow back to the list', async () => {
|
||||
await mountAt('/schema/articles', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(document.body.querySelectorAll('[role="columnheader"]')).toHaveLength(0)
|
||||
const cards = [...document.body.querySelectorAll('[data-testid="schema-field-card"]')]
|
||||
expect(cards.map(text)).toEqual([
|
||||
'Title title · Required · up to 200 characters',
|
||||
'Slug slug · Required · unique',
|
||||
'Body body · Up to 10,000 characters',
|
||||
'Online online · Off by default',
|
||||
'Published on published_on',
|
||||
])
|
||||
expect(text(main())).toContain('Built-in')
|
||||
expect(text(main())).toContain('Managed by RenewCMS')
|
||||
expect(text(main())).toContain(NOTE)
|
||||
const back = document.body.querySelector('a[href="/schema"][aria-label="Schema"]')!
|
||||
expect(back).not.toBeNull()
|
||||
expect(document.body.textContent).toContain('Articles')
|
||||
;(back as HTMLElement).click()
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/schema')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema built-in view: loading', () => {
|
||||
it('shows nothing for 150 ms, then skeleton rows, never the not-found or 403 page', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountAt('/schema/articles', { builtin: () => gate.promise })
|
||||
|
||||
expect(document.body.querySelector('[data-testid="schema-skeleton-row"]')).toBeNull()
|
||||
expect(text(main())).not.toContain(NOT_FOUND_TITLE)
|
||||
|
||||
vi.advanceTimersByTime(SKELETON_DELAY_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="schema-skeleton-row"]').length,
|
||||
).toBeGreaterThan(0)
|
||||
expect(text(main())).not.toContain(NOT_FOUND_TITLE)
|
||||
expect(text(main())).not.toContain(ACCESS_TITLE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema built-in view: unknown key', () => {
|
||||
it.each(['/schema/unknown', '/schema/events', '/schema/new'])(
|
||||
'shows the not-found page at %s without redirecting',
|
||||
async (path) => {
|
||||
await mountAt(path)
|
||||
|
||||
expect(text(main())).toContain(NOT_FOUND_TITLE)
|
||||
expect(text(main())).toContain(NOT_FOUND_TEXT)
|
||||
expect(router.currentRoute.value.path).toBe(path)
|
||||
expect(document.body.querySelector('[data-testid="schema-field-row"]')).toBeNull()
|
||||
expect(main().querySelector('button')).toBeNull()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Schema built-in view: error', () => {
|
||||
it.each([
|
||||
['answers 500', failWith(500)],
|
||||
['fails (network)', networkFailure],
|
||||
])('shows the alert when the request %s, then recovers on Try again', async (_, failure) => {
|
||||
let respond = failure
|
||||
await mountAt('/schema/articles', { builtin: () => respond() })
|
||||
|
||||
expect(text(main())).toContain("Couldn't load content types")
|
||||
expect(text(main())).toContain(
|
||||
'GET /v1/builtin-type failed. Check your connection and try again.',
|
||||
)
|
||||
expect(button('Try again')).toBeDefined()
|
||||
expect(document.body.querySelector('[data-testid="schema-field-row"]')).toBeNull()
|
||||
|
||||
respond = () => builtinTypes
|
||||
button('Try again')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(fieldRows()).toHaveLength(5)
|
||||
expect(text(main())).not.toContain("Couldn't load content types")
|
||||
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema built-in view: retry', () => {
|
||||
it('retries only the built-in types, never the custom ones', async () => {
|
||||
let respond = failWith(500)
|
||||
await mountAt('/schema/articles', { builtin: () => respond() })
|
||||
|
||||
respond = () => builtinTypes
|
||||
button('Try again')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(fieldRows()).toHaveLength(5)
|
||||
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(2)
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema built-in view: forbidden', () => {
|
||||
it('explains instead of redirecting for a user who is not owner or admin', async () => {
|
||||
await mountAt('/schema/articles', { role: 'user' })
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/schema/articles')
|
||||
expect(text(main())).toContain(ACCESS_TITLE)
|
||||
expect(calls).not.toContain('/v1/builtin-type')
|
||||
button('Go to Home')!.click()
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
|
||||
})
|
||||
|
||||
it('shows the same page when the API answers 403', async () => {
|
||||
await mountAt('/schema/articles', { builtin: failWith(403) })
|
||||
|
||||
expect(text(main())).toContain(ACCESS_TITLE)
|
||||
expect(text(main())).not.toContain(NOT_FOUND_TITLE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema built-in view: shared load', () => {
|
||||
it('loads each list once across list, view and back', async () => {
|
||||
await mountAt('/schema')
|
||||
await router.push('/schema/articles')
|
||||
await flushPromises()
|
||||
await router.back()
|
||||
await flushPromises()
|
||||
|
||||
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(1)
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import router from '@/router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
builtinTypes,
|
||||
@@ -60,11 +61,11 @@ describe('Schema list: populated', () => {
|
||||
'Events events 7 15 Oct 2026',
|
||||
'Recipes recipes 4 2 Oct 2026',
|
||||
'Press mentions press-mentions 5 18 Sep 2026',
|
||||
'Articles Built-in articles 5 —',
|
||||
'Authors Built-in authors 5 —',
|
||||
'Sites Built-in sites 8 —',
|
||||
'Media Built-in media 6 —',
|
||||
'Users Built-in users 6 —',
|
||||
'Articles Built-in articles 5 Managed by RenewCMS',
|
||||
'Authors Built-in authors 5 Managed by RenewCMS',
|
||||
'Sites Built-in sites 8 Managed by RenewCMS',
|
||||
'Media Built-in media 6 Managed by RenewCMS',
|
||||
'Users Built-in users 6 Managed by RenewCMS',
|
||||
])
|
||||
const rows = [...document.body.querySelectorAll('[data-testid="schema-row"]')]
|
||||
const [rowIcon, badgeIcon] = rows[3]!.querySelectorAll('svg')
|
||||
@@ -158,6 +159,13 @@ describe('Schema list: error', () => {
|
||||
expect(document.body.querySelector('[role="table"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the Error state when a 200 answer is not an array', async () => {
|
||||
await mountAt('/schema', { content: () => ({ items: [] }) })
|
||||
|
||||
expect(text(main())).toContain("Couldn't load content types")
|
||||
expect(document.body.querySelector('[role="table"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('retries: skeleton after 150 ms, a second request, then the populated state', async () => {
|
||||
let attempt = 0
|
||||
await mountAt('/schema', {
|
||||
@@ -192,6 +200,56 @@ describe('Schema list: error', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema list: retry reloads only what failed', () => {
|
||||
it('retries a failed built-in list without refetching ready custom types', async () => {
|
||||
let respond = failWith(500)
|
||||
await mountAt('/schema', { builtin: () => respond() })
|
||||
expect(text(main())).toContain("Couldn't load content types")
|
||||
|
||||
respond = () => builtinTypes
|
||||
button('Try again')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
||||
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(2)
|
||||
expect(rowTexts()).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('keeps the ready custom items while the built-in retry is pending', async () => {
|
||||
let respond = failWith(500)
|
||||
await mountAt('/schema', { builtin: () => respond() })
|
||||
const store = useContentTypesStore()
|
||||
const gate = pending()
|
||||
vi.mocked(useApi)().request = vi.fn(async (url: string) => {
|
||||
calls.push(url)
|
||||
return gate.promise
|
||||
}) as never
|
||||
|
||||
button('Try again')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(store.status).toBe('ready')
|
||||
expect(store.items).toHaveLength(3)
|
||||
respond = () => builtinTypes
|
||||
gate.resolve(builtinTypes)
|
||||
await flushPromises()
|
||||
expect(rowTexts()).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('retries only the failed custom list when the built-in types are ready', async () => {
|
||||
let respond = failWith(500)
|
||||
await mountAt('/schema', { content: () => respond() })
|
||||
|
||||
respond = () => customTypes
|
||||
button('Try again')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(2)
|
||||
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(1)
|
||||
expect(rowTexts()).toHaveLength(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema list: forbidden', () => {
|
||||
it('explains instead of redirecting for a role that is neither owner nor admin', async () => {
|
||||
await mountAt('/schema', { role: 'unknown' })
|
||||
@@ -221,13 +279,20 @@ describe('Schema list: forbidden', () => {
|
||||
})
|
||||
|
||||
describe('Schema list: accessibility', () => {
|
||||
it('keeps every table child a row, every row child a cell, the divider included', async () => {
|
||||
it('keeps every table child a row or a named link, every row child a cell, the divider included', async () => {
|
||||
await mountAt('/schema')
|
||||
|
||||
const table = document.body.querySelector('[role="table"]')!
|
||||
for (const row of table.children) {
|
||||
expect(row.getAttribute('role')).toBe('row')
|
||||
const isLink = row.tagName === 'A'
|
||||
expect(isLink ? row.getAttribute('aria-label') : row.getAttribute('role')).toBeTruthy()
|
||||
if (!isLink) {
|
||||
expect(row.getAttribute('role')).toBe('row')
|
||||
}
|
||||
for (const cell of row.children) {
|
||||
if (cell.getAttribute('aria-hidden') === 'true') {
|
||||
continue
|
||||
}
|
||||
expect(['cell', 'columnheader', 'presentation']).toContain(
|
||||
cell.getAttribute('role'),
|
||||
)
|
||||
|
||||
@@ -41,13 +41,40 @@ export const customTypes = [
|
||||
},
|
||||
].map((type) => ({ ...type, created_at: '2026-09-01T09:30:00Z' }))
|
||||
|
||||
function articleField(label: string, key: string, type: string, rest: object = {}) {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
type,
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
options: null,
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
export const articleFields = [
|
||||
articleField('Title', 'title', 'text', { required: true, max: 200 }),
|
||||
articleField('Slug', 'slug', 'text', { required: true, unique: true }),
|
||||
articleField('Body', 'body', 'richtext', { max: 10000 }),
|
||||
articleField('Online', 'online', 'boolean', { default: false }),
|
||||
articleField('Published on', 'published_on', 'date'),
|
||||
]
|
||||
|
||||
export const builtinTypes = [
|
||||
['articles', 'Articles', 5],
|
||||
['authors', 'Authors', 5],
|
||||
['sites', 'Sites', 8],
|
||||
['media', 'Media', 6],
|
||||
['users', 'Users', 6],
|
||||
].map(([key, name, count]) => ({ key, name, fields: new Array(count) }))
|
||||
].map(([key, name, count]) => ({
|
||||
key,
|
||||
name,
|
||||
fields: key === 'articles' ? articleFields : new Array(count),
|
||||
}))
|
||||
|
||||
export function manyTypes(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
|
||||
Reference in new issue
Block a user