feat: schema builder's create flow

This commit is contained in:
Florian Sylvain committed 2026-10-20 00:29:01 +02:00
1 parent f514146fed
commit 083cc9e23e
71 files changed
+7223 -61

No files matched your search

@@ -0,0 +1,250 @@
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 { button, calls, failWith, mountAt, text, unmountAll } from './schemaTestKit'
import {
all,
chip,
click,
discardButton,
el,
inspector,
keyField,
keyInput,
labelInput,
main,
nameInput,
rowLabels,
saveButton,
typeInto,
} from './builderKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
afterEach(unmountAll)
const NOT_FOUND_TITLE = 'Content type not found'
const ACCESS_TITLE = "You don't have access to Schema"
function newTypeButtons() {
return all('button').filter((candidate) => text(candidate) === 'New content type')
}
describe('Reaching the builder', () => {
it('opens from the header button', async () => {
await mountAt('/schema')
await click(newTypeButtons()[0])
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
expect(nameInput()).not.toBeNull()
expect(text(main())).not.toContain(NOT_FOUND_TITLE)
})
it('opens from the empty-state button', async () => {
await mountAt('/schema', { content: () => [] })
await click(newTypeButtons().at(-1))
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
expect(nameInput()).not.toBeNull()
})
it('opens from the mobile floating button', async () => {
await mountAt('/schema', { width: MOBILE_WIDTH })
await click(newTypeButtons().find((candidate) => candidate.className.includes('fixed')))
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
expect(nameInput()).not.toBeNull()
})
it.each(['/schema/events', '/schema/unknown'])('still says not found at %s', async (path) => {
await mountAt(path)
expect(text(main())).toContain(NOT_FOUND_TITLE)
expect(nameInput()).toBeNull()
})
})
describe('Roles and sessions', () => {
it.each(['owner', 'admin'])('shows the builder to a verified %s', async (role) => {
await mountAt('/schema/new', { role })
expect(nameInput()).not.toBeNull()
expect(text(main())).not.toContain(ACCESS_TITLE)
})
it('tells a verified user who is neither to go home, leaving the URL', async () => {
await mountAt('/schema/new', { role: 'user' })
expect(text(main())).toContain(ACCESS_TITLE)
expect(button('Go to Home')).toBeInstanceOf(HTMLButtonElement)
expect(router.currentRoute.value.path).toBe('/schema/new')
expect(nameInput()).toBeNull()
expect(el('[data-testid="field-row"]')).toBeNull()
})
it('shows the same page when the content types answer 403', async () => {
await mountAt('/schema/new', { content: failWith(403) })
expect(text(main())).toContain(ACCESS_TITLE)
})
})
describe('The builder needs no built-in list', () => {
it('calls the custom list once, never the built-in one, and shows no skeleton', async () => {
await mountAt('/schema/new')
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
expect(calls).not.toContain('/v1/builtin-type')
expect(nameInput()).not.toBeNull()
expect(el('[data-testid="schema-skeleton-row"]')).toBeNull()
})
it('shows the builder while the custom list is still loading', async () => {
let release!: () => void
const gate = new Promise<void>((done) => (release = done))
await mountAt('/schema/new', { content: () => gate.then(() => []) })
expect(nameInput()).not.toBeNull()
release()
await flushPromises()
})
})
describe('New type, desktop (design state 1)', () => {
it('shows the breadcrumb, Name, Key and the list header', async () => {
await mountAt('/schema/new')
expect(text(el('nav[aria-label="breadcrumb"]'))).toBe('Schema New content type')
expect(el('nav[aria-label="breadcrumb"] a[href="/schema"]')).not.toBeNull()
expect(all('nav[aria-label="breadcrumb"] a')).toHaveLength(1)
expect(nameInput()!.value).toBe('')
expect(nameInput()!.placeholder).toBe('For example, Events')
expect(nameInput()!.required).toBe(true)
expect(nameInput()!.maxLength).toBe(100)
expect(keyInput()!.value).toBe('')
expect(keyInput()!.placeholder).toBe('events')
expect(keyInput()!.readOnly).toBe(true)
expect(text(main())).toContain(
"Used in URLs and the API. Lowercase letters, digits and hyphens. It can't change after you save.",
)
expect(text(main())).toContain('Fields')
expect(text(el('[data-testid="field-count"]'))).toBe('1 of 50')
})
it('shows the starter row and the inspector of Title', async () => {
await mountAt('/schema/new')
expect(rowLabels()).toEqual(['Title'])
const row = el('[data-testid="field-row"]')!
expect(text(row.querySelector('[data-testid="field-row-key"]'))).toBe('title')
expect(row.querySelector('[data-field-row]')!.getAttribute('aria-current')).toBe('true')
expect(text(row.querySelector('[data-testid="field-row-badges"]'))).toBe('New')
expect(button('Add field')).toBeInstanceOf(HTMLButtonElement)
expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Title')
expect(labelInput()!.value).toBe('Title')
expect(el<HTMLInputElement>('input[name="field-key"]')!.value).toBe('title')
const copy = text(inspector())
expect(copy).toContain('Auto')
expect(copy).toContain(
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
)
expect(all('[role="radio"]')).toHaveLength(6)
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
'Text',
)
expect(copy).toContain("An entry can't be saved with this field empty.")
expect(copy).toContain('Unique values')
expect(copy).toContain('Used when an entry is created and this field is left out.')
expect(copy).toContain('Shortest')
expect(copy).toContain('Longest')
expect(copy).toContain('Counted in characters. Entries show a counter.')
expect(all('[role="switch"]').map((toggle) => toggle.getAttribute('aria-checked'))).toEqual(
['false', 'false'],
)
})
it('disables Save and Discard, shows no chip, no entries bar and a collapsed API bar', async () => {
await mountAt('/schema/new')
expect(saveButton()!.disabled).toBe(true)
expect(discardButton()!.disabled).toBe(true)
expect(chip()).toBe('')
expect(text(main())).not.toContain('entries use this content type')
expect(el('[data-testid="api-preview-toggle"]')!.getAttribute('aria-expanded')).toBe(
'false',
)
expect(el('[data-testid="api-json"]')).toBeNull()
})
})
describe('New type, mobile (design state 1)', () => {
it('shows the topbar, stacked Name and Key, the list and the API preview row', async () => {
await mountAt('/schema/new', { width: MOBILE_WIDTH })
expect(el('a[href="/schema"][aria-label="Schema"]')).not.toBeNull()
expect(text(el('[data-slot="title"]'))).toBe('New content type')
expect(nameInput()).not.toBeNull()
expect(keyInput()).not.toBeNull()
expect(rowLabels()).toEqual(['Title'])
expect(text(el('[data-testid="api-row"]'))).toBe('API preview')
expect(el('[data-testid="api-row"] svg')).not.toBeNull()
expect(inspector()).toBeNull()
expect(el('[data-testid="inspector-pane"]')).toBeNull()
expect(discardButton()).toBeInstanceOf(HTMLButtonElement)
expect(saveButton()).toBeInstanceOf(HTMLButtonElement)
expect(el('[data-testid="api-preview"]')).toBeNull()
})
})
describe('The key follows the name until it is edited', () => {
it.each([
['Events', 'events', 'auto'],
['Press mentions', 'press-mentions', 'auto'],
['Café & Bar', 'cafe-bar', 'auto'],
[' Job openings!', 'job-openings', 'auto'],
['2026 Awards', '2026-awards', 'auto'],
['!!!', '', 'empty'],
['x'.repeat(60), 'x'.repeat(40), 'auto'],
])('%j gives the key %j', async (name, key, mode) => {
await mountAt('/schema/new')
await typeInto(nameInput(), name)
expect(keyInput()!.value).toBe(key)
expect(keyInput()!.readOnly).toBe(true)
expect(text(keyField()).includes('Auto')).toBe(mode === 'auto')
})
it('unlocks with the pencil, keeps a typed key, and resets to the name', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
await click(keyField().querySelector('button[aria-label="Edit key"]'))
expect(keyInput()!.readOnly).toBe(false)
expect(text(keyField())).not.toContain('Auto')
expect(button('Reset to match the name')).toBeInstanceOf(HTMLButtonElement)
await typeInto(keyInput(), 'agenda')
await typeInto(nameInput(), 'Events and talks')
expect(keyInput()!.value).toBe('agenda')
await click(button('Reset to match the name'))
expect(keyInput()!.value).toBe('events-and-talks')
expect(keyInput()!.readOnly).toBe(true)
expect(text(keyField())).toContain('Auto')
})
})