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,58 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import ApiPreview from '../ApiPreview.vue'
import { mountInApp, text, unmountApp, holder } from './directoryKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
const writeText = vi.fn()
beforeEach(() => {
writeText.mockReset().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
})
afterEach(unmountApp)
const JSON_TEXT = '{\n "id": 42\n}'
const toggle = () => document.body.querySelector<HTMLElement>('[data-testid="api-preview-toggle"]')!
describe('ApiPreview', () => {
it('is collapsed, then opens and closes with the toggle', async () => {
await mountInApp(ApiPreview, { keyValue: 'recipes', json: JSON_TEXT })
expect(toggle().getAttribute('aria-expanded')).toBe('false')
expect(document.body.querySelector('[data-testid="api-json"]')).toBeNull()
toggle().click()
await flushPromises()
expect(toggle().getAttribute('aria-expanded')).toBe('true')
expect(document.body.querySelector('[data-testid="api-json"]')!.textContent).toBe(JSON_TEXT)
expect(holder.wrapper!.findComponent(ApiPreview).emitted('update:open')![0]).toEqual([true])
})
it('shows the first route in the bar and falls back to events', async () => {
await mountInApp(ApiPreview, { keyValue: '', json: JSON_TEXT })
expect(text(document.body.querySelector('[data-testid="api-preview"]'))).toContain(
'GET /v1/content-type/events/entry',
)
})
it('copies the JSON and reads Copied', async () => {
await mountInApp(ApiPreview, { keyValue: 'recipes', json: JSON_TEXT })
document.body.querySelector<HTMLElement>('[data-testid="api-copy"]')!.click()
await flushPromises()
expect(writeText).toHaveBeenCalledWith(JSON_TEXT)
expect(text(document.body.querySelector('[data-testid="api-copy"]'))).toBe('Copied')
})
})
@@ -0,0 +1,176 @@
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 ChoiceList from '../ChoiceList.vue'
import { mountInApp, text, unmountApp, holder } from './directoryKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
afterEach(unmountApp)
function props(extra: Record<string, unknown> = {}) {
return { options: ['Talk', 'Workshop', 'Meetup'], disabled: false, ...extra }
}
function emitted(name: string) {
return holder.wrapper!.findComponent(ChoiceList).emitted(name) ?? []
}
const addInput = () =>
document.body.querySelector<HTMLInputElement>('input[aria-label="Add a choice"]')!
async function type(input: HTMLInputElement, value: string) {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
function addButton() {
return [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
(candidate) => text(candidate) === 'Add',
)!
}
describe('ChoiceList', () => {
it('shows the count, the rows and the help', async () => {
await mountInApp(ChoiceList, props())
expect(text(document.body.querySelector('[data-testid="choice-count"]'))).toBe('3 of 50')
expect(document.body.querySelectorAll('[data-testid="choice-row"]')).toHaveLength(3)
expect(text(document.body)).toContain('Add 1 to 50 choices.')
})
it('emits add from the button and from Enter, and ignores a blank draft', async () => {
await mountInApp(ChoiceList, props())
expect(addButton().disabled).toBe(true)
await type(addInput(), 'Panel')
addButton().click()
await flushPromises()
await type(addInput(), 'Social')
addInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await flushPromises()
await type(addInput(), ' ')
addInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await flushPromises()
expect(emitted('add')).toEqual([['Panel'], ['Social']])
expect(addInput().value).toBe(' ')
})
it('emits rename and remove for a row', async () => {
await mountInApp(ChoiceList, props())
await type(
document.body.querySelectorAll<HTMLInputElement>(
'[data-testid="choice-row"] input',
)[1]!,
'Seminar',
)
document.body.querySelector<HTMLElement>('[aria-label="Remove Talk"]')!.click()
await flushPromises()
expect(emitted('rename')).toEqual([[1, 'Seminar']])
expect(emitted('remove')).toEqual([[0]])
})
it('shows a row error with an alert icon and ties it to the input', async () => {
await mountInApp(ChoiceList, props({ rowErrors: { 1: 'Two choices are the same.' } }))
const row = document.body.querySelectorAll('[data-testid="choice-row"]')[1]!
expect(text(row.querySelector('[role="alert"]'))).toBe('Two choices are the same.')
expect(row.querySelector('[role="alert"] svg')).not.toBeNull()
const input = row.querySelector('input')!
expect(input.getAttribute('aria-invalid')).toBe('true')
expect(input.getAttribute('aria-describedby')).toBe('choice-error-1')
})
it('shows a list error and disables Add at 50 choices', async () => {
await mountInApp(
ChoiceList,
props({ options: Array.from({ length: 50 }, (_, index) => `C${index}`) }),
)
await type(addInput(), 'more')
expect(addButton().disabled).toBe(true)
})
it('shows the list error', async () => {
await mountInApp(ChoiceList, props({ options: [], listError: 'Add at least one choice.' }))
expect(text(document.body.querySelector('p[role="alert"]'))).toBe(
'Add at least one choice.',
)
})
it('emits the new order on drop only', async () => {
await mountInApp(ChoiceList, props())
const grip = () => document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
const key = async (name: string) => {
grip().dispatchEvent(
new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true }),
)
await flushPromises()
}
await key(' ')
await key('ArrowDown')
expect(emitted('reorder')).toHaveLength(0)
await key(' ')
expect(emitted('reorder')).toEqual([[['Workshop', 'Talk', 'Meetup']]])
})
it('keeps each error with its choice while a row is being moved', async () => {
await mountInApp(
ChoiceList,
props({
options: ['Talk', 'Talk', 'Meetup'],
rowErrors: { 1: 'Two choices are the same.' },
}),
)
const grip = document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
grip.dispatchEvent(
new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }),
)
await flushPromises()
grip.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
)
await flushPromises()
const rows = [...document.body.querySelectorAll('[data-testid="choice-row"]')]
expect(rows.map((row) => text(row.querySelector('[role="alert"]')))).toEqual([
'Two choices are the same.',
'',
'',
])
})
it('emits the data index for rename and remove while a row is being moved', async () => {
await mountInApp(ChoiceList, props())
const grip = document.body.querySelector<HTMLElement>('[aria-label="Move Talk"]')!
grip.dispatchEvent(
new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }),
)
await flushPromises()
grip.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
)
await flushPromises()
const removers = [...document.body.querySelectorAll<HTMLButtonElement>('button')].filter(
(candidate) => candidate.getAttribute('aria-label')?.startsWith('Remove '),
)
removers[0]!.click()
await flushPromises()
expect(emitted('remove')).toEqual([[1]])
})
})
@@ -0,0 +1,107 @@
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 FieldInspector from '../FieldInspector.vue'
import { createField } from '@/lib/schemaBuilderModel'
import { mountInApp, text, unmountApp, holder } from './directoryKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
afterEach(unmountApp)
function props(extra: Record<string, unknown> = {}) {
return {
field: createField(1, { label: 'Title', key: 'title' }),
errors: {},
disabled: false,
compact: false,
focusToken: 0,
...extra,
}
}
function emitted(name: string) {
return holder.wrapper!.findComponent(FieldInspector).emitted(name) ?? []
}
async function type(selector: string, value: string) {
const input = document.body.querySelector<HTMLInputElement>(selector)!
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
describe('FieldInspector', () => {
it('emits label, key, type, limit and default edits', async () => {
await mountInApp(
FieldInspector,
props({ field: createField(1, { label: 'T', key: 't', keyMode: 'manual' }) }),
)
await type('input[name="label"]', 'Headline')
await type('input[name="field-key"]', 'headline')
await type('input[name="max"]', '80')
await type('input[name="default"]', 'Draft')
document.body.querySelector<HTMLElement>('[role="radio"][value="number"]')!.click()
await flushPromises()
expect(emitted('label')).toEqual([['Headline']])
expect(emitted('key')).toEqual([['headline']])
expect(emitted('limit')).toEqual([['max', '80']])
expect(emitted('default')).toEqual([['Draft']])
expect(emitted('type')).toEqual([['number']])
})
it('emits the Required switch', async () => {
await mountInApp(FieldInspector, props())
const label = [...document.body.querySelectorAll('label')].find((node) =>
text(node).startsWith('Required'),
)!
document.getElementById(label.getAttribute('for')!)!.click()
await flushPromises()
expect(emitted('required')).toEqual([[true]])
})
it('dims and disables every control while disabled', async () => {
await mountInApp(FieldInspector, props({ disabled: true }))
const root = document.body.querySelector('[data-testid="field-inspector"]')!
expect(root.className).toContain('opacity-60')
expect(
[...root.querySelectorAll<HTMLInputElement>('input')].every((input) => input.disabled),
).toBe(true)
})
it('shows the errors under their controls', async () => {
await mountInApp(
FieldInspector,
props({
field: createField(1, { type: 'number', min: '6', max: '5', defaultValue: '0' }),
errors: {
label: 'Give the field a label.',
key: 'Bad key.',
min: 'Order.',
default: 'Outside.',
},
}),
)
const alerts = [...document.body.querySelectorAll('[role="alert"]')].map(text)
expect(alerts).toEqual(['Give the field a label.', 'Bad key.', 'Outside.', 'Order.'])
})
it('lays the controls out in one column when compact', async () => {
await mountInApp(FieldInspector, props({ compact: true }))
expect(document.body.querySelector('.grid-cols-1')).not.toBeNull()
expect(document.body.querySelector('.grid-cols-2')).not.toBeNull()
})
})
@@ -0,0 +1,141 @@
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 FieldList from '../FieldList.vue'
import { createField, type BuilderField } from '@/lib/schemaBuilderModel'
import { mountInApp, text, unmountApp, holder } from './directoryKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
afterEach(unmountApp)
const FIELDS: BuilderField[] = [
createField(1, { label: 'Title', key: 'title' }),
createField(2, { label: 'Starts on', key: 'starts_on', type: 'date' }),
createField(3, { label: 'Ends on', key: 'ends_on', type: 'date' }),
]
function props(extra: Record<string, unknown> = {}) {
return {
fields: FIELDS,
selectedUid: 2,
errors: new Map(),
disabled: false,
mobile: false,
...extra,
}
}
function emitted(name: string) {
return holder.wrapper!.findComponent(FieldList).emitted(name) ?? []
}
function grip(label: string) {
return document.body.querySelector<HTMLElement>(`[aria-label="Move ${label}"]`)!
}
async function press(target: Element, key: string) {
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
await flushPromises()
}
function labels() {
return [...document.body.querySelectorAll('[data-testid="field-row-label"]')].map(text)
}
describe('FieldList', () => {
it('lists the rows and counts them against 50', async () => {
await mountInApp(FieldList, props())
expect(labels()).toEqual(['Title', 'Starts on', 'Ends on'])
expect(text(document.body.querySelector('[data-testid="field-count"]'))).toBe('3 of 50')
})
it('emits select, add, move and remove', async () => {
await mountInApp(FieldList, props())
document.body.querySelector<HTMLElement>('[data-field-row][data-uid="3"]')!.click()
;(document.body.querySelector('[data-action="up"]') as HTMLElement).click()
;(document.body.querySelector('[data-action="remove"]') as HTMLElement).click()
const add = [...document.body.querySelectorAll('button')].find(
(candidate) => text(candidate) === 'Add field',
)!
add.click()
await flushPromises()
expect(emitted('select')).toEqual([[3]])
expect(emitted('move')).toEqual([[2, -1]])
expect(emitted('remove')).toEqual([[2]])
expect(emitted('add')).toHaveLength(1)
})
it('disables Add field at 50 rows and when disabled', async () => {
const many = Array.from({ length: 50 }, (_, index) =>
createField(index + 1, { label: 'F' }),
)
await mountInApp(FieldList, props({ fields: many, selectedUid: 1 }))
const add = [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
(candidate) => text(candidate) === 'Add field',
)!
expect(add.disabled).toBe(true)
expect(text(document.body.querySelector('[data-testid="field-count"]'))).toBe('50 of 50')
})
it('shows the empty state with the error message in the error colour', async () => {
await mountInApp(FieldList, props({ fields: [], selectedUid: null, listError: 'Nope.' }))
const empty = document.body.querySelector('[data-testid="field-empty"]')!
expect(text(empty)).toBe('No fields yet Nope.')
expect(empty.querySelector('.text-error')).not.toBeNull()
})
it('emits the new order once, on drop, and nothing on cancel', async () => {
await mountInApp(FieldList, props())
await press(grip('Starts on'), ' ')
await press(grip('Starts on'), 'ArrowUp')
await press(grip('Starts on'), 'Escape')
expect(emitted('reorder')).toHaveLength(0)
expect(labels()).toEqual(['Title', 'Starts on', 'Ends on'])
await press(grip('Starts on'), ' ')
await press(grip('Starts on'), 'ArrowUp')
expect(emitted('reorder')).toHaveLength(0)
await press(grip('Starts on'), ' ')
expect(emitted('reorder')).toEqual([[[2, 1, 3]]])
})
it('moves the focus between rows with the arrow keys', async () => {
await mountInApp(FieldList, props())
const rows = [...document.body.querySelectorAll<HTMLElement>('[data-field-row]')]
rows[0]!.focus()
await press(rows[0]!, 'ArrowDown')
expect(document.activeElement).toBe(rows[1])
await press(rows[1]!, 'ArrowUp')
expect(document.activeElement).toBe(rows[0])
})
it('keeps one roving tab stop for the rows', async () => {
await mountInApp(FieldList, props())
const stops = [...document.body.querySelectorAll<HTMLElement>('[data-field-row]')].filter(
(row) => row.tabIndex === 0,
)
expect(stops.map((row) => row.getAttribute('data-uid'))).toEqual(['2'])
})
it('hides the row actions on mobile', async () => {
await mountInApp(FieldList, props({ mobile: true }))
expect(document.body.querySelector('[data-action]')).toBeNull()
})
})