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

+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { moveItem } from '@/lib/arrayMove'
describe('moveItem', () => {
it('moves an item forward and backward without touching the input', () => {
const items = ['a', 'b', 'c']
expect(moveItem(items, 0, 2)).toEqual(['b', 'c', 'a'])
expect(moveItem(items, 2, 0)).toEqual(['c', 'a', 'b'])
expect(items).toEqual(['a', 'b', 'c'])
})
it.each([
[1, 1],
[-1, 0],
[0, -1],
[3, 0],
[0, 3],
])('returns the same array for %i to %i', (from, to) => {
const items = ['a', 'b', 'c']
expect(moveItem(items, from, to)).toBe(items)
})
})
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { deriveFieldKey, deriveTypeKey } from '@/lib/schemaBuilderKeys'
describe('type key from the name', () => {
it.each([
['Events', 'events'],
['Press mentions', 'press-mentions'],
['Café & Bar', 'cafe-bar'],
[' Job openings! ', 'job-openings'],
['2026 Awards', '2026-awards'],
['!!!', ''],
['', ''],
])('%j gives %j', (name, key) => {
expect(deriveTypeKey(name)).toBe(key)
})
it('keeps the first 40 characters of a long name', () => {
const name = 'abcdefghij'.repeat(6)
expect(deriveTypeKey(name)).toBe(name.slice(0, 40))
})
it('drops a hyphen left at the end by the cut', () => {
const name = `${'a'.repeat(39)} bbbbbbbbbb`
expect(deriveTypeKey(name)).toBe('a'.repeat(39))
})
})
describe('field key from the label', () => {
it.each([
['Title', 'title'],
['Starts on', 'starts_on'],
['Éléments', 'elements'],
['Ends on (date)', 'ends_on_date'],
['2nd room', 'nd_room'],
['!!!', ''],
['', ''],
])('%j gives %j', (label, key) => {
expect(deriveFieldKey(label, [])).toBe(key)
})
it('adds _2 when the key already exists, then _3', () => {
expect(deriveFieldKey('Title', ['title'])).toBe('title_2')
expect(deriveFieldKey('Title', ['title', 'title_2'])).toBe('title_3')
})
it('keeps the first 40 characters of a long label', () => {
const label = 'abcdefghij'.repeat(6)
expect(deriveFieldKey(label, [])).toBe(label.slice(0, 40))
})
it('keeps the suffixed key within 40 characters', () => {
const long = 'a'.repeat(40)
const key = deriveFieldKey(long, [long])
expect(key).toBe(`${'a'.repeat(38)}_2`)
})
})
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import {
blankForm,
changeFieldType,
clearStaleDefault,
createField,
effectiveDefault,
isUnique,
keyDisplayMode,
} from '@/lib/schemaBuilderModel'
describe('changing the type of a field', () => {
const number = createField(7, {
label: 'Capacity',
key: 'capacity',
keyMode: 'manual',
type: 'number',
required: true,
unique: true,
defaultValue: '20',
min: '1',
max: '5000',
})
it('keeps the label, key and Required and resets the rest', () => {
const next = changeFieldType(number, 'boolean')
expect(next).toEqual(
createField(7, {
label: 'Capacity',
key: 'capacity',
keyMode: 'manual',
type: 'boolean',
required: true,
}),
)
})
it('starts a Choice field with no choice', () => {
expect(changeFieldType(changeFieldType(number, 'boolean'), 'select').options).toEqual([])
})
it('returns the same field for the same type', () => {
expect(changeFieldType(number, 'number')).toBe(number)
})
})
describe('blank form', () => {
it('holds the starter field Title', () => {
const form = blankForm(1)
expect([form.name, form.key, form.keyMode]).toEqual(['', '', 'auto'])
expect(form.fields).toHaveLength(1)
expect([form.fields[0]!.label, form.fields[0]!.key, form.fields[0]!.type]).toEqual([
'Title',
'title',
'text',
])
})
})
describe('stale default', () => {
it('clears a Choice default that is no longer a choice', () => {
const field = createField(1, { type: 'select', options: ['A'], defaultValue: 'B' })
expect(clearStaleDefault(field).defaultValue).toBe('')
})
it('keeps a default that is still a choice', () => {
const field = createField(1, { type: 'select', options: ['A'], defaultValue: 'A' })
expect(clearStaleDefault(field)).toBe(field)
})
})
describe('key display mode', () => {
it.each([
['manual', '', 'manual'],
['manual', 'title', 'manual'],
['auto', 'title', 'auto'],
['auto', '', 'empty'],
] as const)('%s with key %j is %s', (mode, key, expected) => {
expect(keyDisplayMode(mode, key)).toBe(expected)
})
})
describe('effective default', () => {
it('is the typed default for a plain field', () => {
expect(effectiveDefault(createField(1, { defaultValue: 'x' }))).toBe('x')
})
it('is empty for a unique field that can be unique', () => {
const field = createField(1, { defaultValue: 'x', unique: true })
expect(isUnique(field)).toBe(true)
expect(effectiveDefault(field)).toBe('')
})
it('ignores unique on a type that cannot be unique', () => {
const field = createField(1, { type: 'richtext', defaultValue: 'x', unique: true })
expect(isUnique(field)).toBe(false)
expect(effectiveDefault(field)).toBe('x')
})
it('keeps a select default only while it is one of the choices', () => {
const field = createField(1, { type: 'select', options: [' Talk '], defaultValue: 'Talk' })
expect(effectiveDefault(field)).toBe('Talk')
expect(effectiveDefault({ ...field, defaultValue: 'Other' })).toBe('')
})
})
@@ -0,0 +1,190 @@
import { describe, expect, it } from 'vitest'
import { createField, type BuilderForm } from '@/lib/schemaBuilderModel'
import { buildPayload } from '@/lib/schemaBuilderPayload'
const EVENTS: BuilderForm = {
name: ' Events ',
key: 'events',
keyMode: 'auto',
fields: [
createField(1, { label: 'Title', key: 'title', required: true, unique: true, max: '80' }),
createField(2, {
label: 'Starts on',
key: 'starts_on',
type: 'date',
required: true,
min: '2026-01-01',
max: '2027-12-31',
}),
createField(3, { label: 'Online', key: 'online', type: 'boolean', defaultOn: true }),
createField(4, {
label: 'Capacity',
key: 'capacity',
type: 'number',
defaultValue: '20',
min: '1',
max: '5000',
}),
createField(5, {
label: 'Category',
key: 'category',
type: 'select',
defaultValue: 'Talk',
options: [' Talk', 'Workshop ', 'Meetup'],
}),
createField(6, { label: 'Description', key: 'description', type: 'richtext', max: '5000' }),
],
}
function field(key: string, rest: object) {
return { key, label: key, type: 'text', required: false, unique: false, ...rest }
}
describe('create payload', () => {
it('builds the exact body for the six-field Events form', () => {
expect(buildPayload(EVENTS)).toEqual({
key: 'events',
name: 'Events',
fields: [
{
...field('title', {}),
label: 'Title',
required: true,
unique: true,
default: null,
min: null,
max: 80,
options: [],
},
{
key: 'starts_on',
label: 'Starts on',
type: 'date',
required: true,
unique: false,
default: null,
min: '2026-01-01',
max: '2027-12-31',
options: [],
},
{
key: 'online',
label: 'Online',
type: 'boolean',
required: false,
unique: false,
default: true,
min: null,
max: null,
options: [],
},
{
key: 'capacity',
label: 'Capacity',
type: 'number',
required: false,
unique: false,
default: 20,
min: 1,
max: 5000,
options: [],
},
{
key: 'category',
label: 'Category',
type: 'select',
required: false,
unique: false,
default: 'Talk',
min: null,
max: null,
options: ['Talk', 'Workshop', 'Meetup'],
},
{
key: 'description',
label: 'Description',
type: 'richtext',
required: false,
unique: false,
default: null,
min: null,
max: 5000,
options: [],
},
],
})
})
it('has no icon key and exactly the nine field keys', () => {
const body = buildPayload(EVENTS)
expect(Object.keys(body).sort()).toEqual(['fields', 'key', 'name'])
for (const sent of body.fields) {
expect(Object.keys(sent).sort()).toEqual([
'default',
'key',
'label',
'max',
'min',
'options',
'required',
'type',
'unique',
])
}
})
it('sends null for an off switch', () => {
const body = buildPayload({
...EVENTS,
fields: [createField(1, { label: 'On', key: 'on', type: 'boolean' })],
})
expect(body.fields[0]!.default).toBeNull()
})
it('does not send the default of a unique field', () => {
const body = buildPayload({
...EVENTS,
fields: [createField(1, { label: 'T', key: 't', unique: true, defaultValue: 'Draft' })],
})
expect(body.fields[0]!.default).toBeNull()
})
it('sends no unique flag for rich text and yes or no fields', () => {
const body = buildPayload({
...EVENTS,
fields: [
createField(1, { label: 'R', key: 'r', type: 'richtext', unique: true }),
createField(2, { label: 'B', key: 'b', type: 'boolean', unique: true }),
],
})
expect(body.fields.map((sent) => sent.unique)).toEqual([false, false])
})
it('sends no limits for yes or no and choice fields', () => {
const body = buildPayload({
...EVENTS,
fields: [
createField(1, { label: 'B', key: 'b', type: 'boolean', min: '1', max: '2' }),
createField(2, { label: 'S', key: 's', type: 'select', min: '1', options: ['A'] }),
],
})
expect(body.fields.map((sent) => [sent.min, sent.max])).toEqual([
[null, null],
[null, null],
])
})
it('keeps a limit of zero', () => {
const body = buildPayload({
...EVENTS,
fields: [createField(1, { label: 'T', key: 't', min: '0' })],
})
expect(body.fields[0]!.min).toBe(0)
})
})
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { createField, type BuilderField } from '@/lib/schemaBuilderModel'
import { previewRoutes, sampleEntry, sampleJson } from '@/lib/schemaBuilderSample'
function valueOf(overrides: Partial<BuilderField>) {
const field = createField(1, { label: 'F', key: 'f', ...overrides })
return sampleEntry('events', [field]).data.f
}
describe('sample values', () => {
it.each([
['text', {}, 'Example text'],
['text', { defaultValue: 'Draft' }, 'Draft'],
['richtext', {}, 'Markdown text'],
['number', {}, 0],
['number', { min: '1' }, 1],
['number', { min: '1', defaultValue: '20' }, 20],
['boolean', {}, false],
['boolean', { defaultOn: true }, true],
['date', {}, '2026-11-21'],
['select', { options: ['Talk', 'Workshop'] }, 'Talk'],
['select', { options: ['Talk', 'Workshop'], defaultValue: 'Workshop' }, 'Workshop'],
['select', {}, null],
] as const)('%s with %j holds %j', (type, settings, expected) => {
expect(valueOf({ type, ...settings } as Partial<BuilderField>)).toBe(expected)
})
it('ignores the default of a unique field', () => {
expect(valueOf({ unique: true, defaultValue: 'Draft' })).toBe('Example text')
})
})
describe('sample entry', () => {
const fields = [
createField(1, { label: 'Title', key: 'title' }),
createField(2, { label: 'Bad', key: 'start-date', keyMode: 'manual' }),
createField(3, { label: 'Online', key: 'online', type: 'boolean' }),
]
it('wraps the data in the fixed envelope, in field order, skipping invalid keys', () => {
expect(sampleEntry('events', fields)).toEqual({
id: 42,
content_type: 'events',
data: { title: 'Example text', online: false },
created_by: 3,
updated_by: 3,
created_at: '2026-10-15T11:02:00Z',
updated_at: '2026-10-15T11:02:00Z',
})
expect(Object.keys(sampleEntry('events', fields).data)).toEqual(['title', 'online'])
})
it('indents by two spaces', () => {
expect(sampleJson('events', fields.slice(0, 1))).toBe(
JSON.stringify(sampleEntry('events', fields.slice(0, 1)), null, 2),
)
expect(sampleJson('events', fields.slice(0, 1))).toContain('\n "id": 42,')
})
it('falls back to the key events while the key is empty', () => {
expect(sampleEntry('', []).content_type).toBe('events')
expect(previewRoutes('')[0]!.path).toBe('/v1/content-type/events/entry')
})
it('lists the five routes of the type', () => {
expect(previewRoutes('recipes')).toEqual([
{ method: 'GET', path: '/v1/content-type/recipes/entry' },
{ method: 'POST', path: '/v1/content-type/recipes/entry' },
{ method: 'GET', path: '/v1/content-type/recipes/entry/{id}' },
{ method: 'PUT', path: '/v1/content-type/recipes/entry/{id}' },
{ method: 'DELETE', path: '/v1/content-type/recipes/entry/{id}' },
])
})
})
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { FIELD_ERROR_TOUCH, fieldTouchKey, typeTouchKey } from '@/lib/schemaBuilderTouch'
describe('touch keys', () => {
it('keeps type and field keys apart', () => {
expect(typeTouchKey('key')).toBe('type:key')
expect(fieldTouchKey(3, 'key')).toBe('field:3:key')
expect(fieldTouchKey(3, 'key')).not.toBe(fieldTouchKey(33, 'key'))
expect(fieldTouchKey(1, 'label')).not.toBe(fieldTouchKey(1, 'key'))
})
it('maps both choice errors to the choices touch', () => {
const choices = FIELD_ERROR_TOUCH.filter(([, touch]) => touch === 'choices').map(
([error]) => error,
)
expect(choices).toEqual(['options', 'choices'])
})
})
@@ -0,0 +1,249 @@
import { describe, expect, it } from 'vitest'
import {
createField,
starterField,
type BuilderField,
type BuilderForm,
} from '@/lib/schemaBuilderModel'
import {
countProblems,
validateField,
validateForm,
validateType,
} from '@/lib/schemaBuilderValidation'
const KEY_MESSAGE = 'Use 2 to 40 lowercase letters, digits or hyphens.'
function form(overrides: Partial<BuilderForm> = {}): BuilderForm {
return {
name: 'Events',
key: 'events',
keyMode: 'auto',
fields: [starterField(1)],
...overrides,
}
}
function field(overrides: Partial<BuilderField> = {}) {
return createField(2, { label: 'Thing', key: 'thing', ...overrides })
}
describe('type validation', () => {
it.each([
['', 'Give the content type a name.'],
[' ', 'Give the content type a name.'],
['a'.repeat(101), 'Use 100 characters or fewer.'],
['a'.repeat(100), undefined],
['Events', undefined],
])('name %j', (name, message) => {
expect(validateType(form({ name })).name).toBe(message)
})
it.each([
['e', KEY_MESSAGE],
['Events', KEY_MESSAGE],
['my_type', KEY_MESSAGE],
['-events', KEY_MESSAGE],
['events-', KEY_MESSAGE],
['events--2', KEY_MESSAGE],
['', KEY_MESSAGE],
['a'.repeat(41), KEY_MESSAGE],
['new', 'The key "new" is reserved. Choose a different key.'],
['ab', undefined],
['press-mentions', undefined],
['2026-awards', undefined],
['a'.repeat(40), undefined],
])('key %j', (key, message) => {
expect(validateType(form({ key })).key).toBe(message)
})
it.each([
[0, 'A content type needs at least one field.'],
[1, undefined],
[50, undefined],
[51, 'A content type can have up to 50 fields.'],
])('%i fields', (count, message) => {
const fields = Array.from({ length: count }, (_, index) => starterField(index))
expect(validateType(form({ fields })).fields).toBe(message)
})
})
describe('field validation', () => {
const KEY_RULE = 'Start with a letter. Use lowercase letters, digits and underscores.'
it.each([
['', 'Give the field a label.'],
[' ', 'Give the field a label.'],
['Title', undefined],
])('label %j', (label, message) => {
expect(validateField(field({ label }), []).label).toBe(message)
})
it.each([
['Title', KEY_RULE],
['1st', KEY_RULE],
['a-b', KEY_RULE],
['a b', KEY_RULE],
['', KEY_RULE],
['_a', KEY_RULE],
['a'.repeat(41), KEY_RULE],
['starts_on', undefined],
['a', undefined],
['a'.repeat(40), undefined],
])('manual key %j', (key, message) => {
expect(validateField(field({ key, keyMode: 'manual' }), []).key).toBe(message)
})
it('leaves the key error to the label while an automatic key follows a blank label', () => {
const errors = validateField(field({ label: '', key: '' }), [])
expect(errors.key).toBeUndefined()
expect(errors.label).toBe('Give the field a label.')
})
it('flags a key already used by an earlier field', () => {
expect(validateField(field({ key: 'title' }), ['title']).key).toBe(
'Another field already uses this key.',
)
})
it('flags only the second of two equal keys', () => {
const first = createField(1, { label: 'A', key: 'title', keyMode: 'manual' })
const second = createField(2, { label: 'B', key: 'title', keyMode: 'manual' })
const errors = validateForm(form({ fields: [first, second] }))
expect([...errors.fields.keys()]).toEqual([2])
expect(errors.fields.get(2)!.key).toBe('Another field already uses this key.')
})
it.each([
[
'number',
{ min: '6000', max: '5000' },
'min',
"Lowest value can't be above the highest value.",
],
['text', { min: '10', max: '3' }, 'min', "Shortest can't be longer than longest."],
[
'date',
{ min: '2027-12-31', max: '2026-01-01' },
'min',
"Earliest date can't be after the latest date.",
],
['text', { min: '2.5' }, 'min', 'Use a whole number, 0 or more.'],
['text', { min: '-1' }, 'min', 'Use a whole number, 0 or more.'],
['richtext', { max: 'abc' }, 'max', 'Use a whole number, 0 or more.'],
[
'number',
{ min: '1', max: '5000', defaultValue: '0' },
'default',
'The default value is outside the limits.',
],
[
'number',
{ max: '10', defaultValue: '11' },
'default',
'The default value is outside the limits.',
],
['number', { defaultValue: 'x' }, 'default', 'Enter a number.'],
[
'text',
{ max: '3', defaultValue: 'abcd' },
'default',
'The default value is outside the limits.',
],
[
'text',
{ min: '3', defaultValue: 'ab' },
'default',
'The default value is outside the limits.',
],
[
'date',
{ min: '2026-01-01', max: '2027-12-31', defaultValue: '2028-01-01' },
'default',
'The default value is outside the limits.',
],
['select', { options: [] }, 'options', 'Add at least one choice.'],
['number', { min: '1', max: '5000' }, 'min', undefined],
['number', { min: '5', max: '5' }, 'min', undefined],
['text', { min: '0' }, 'min', undefined],
['number', { min: '1', max: '5000', defaultValue: '20' }, 'default', undefined],
['text', { max: '3', defaultValue: 'abc' }, 'default', undefined],
] as const)('%s with %j reports %s as %j', (type, settings, where, message) => {
const errors = validateField(field({ type, ...settings } as Partial<BuilderField>), [])
expect(errors[where]).toBe(message)
})
it('skips the default check while unique values drop the default', () => {
const errors = validateField(field({ max: '3', defaultValue: 'abcd', unique: true }), [])
expect(errors.default).toBeUndefined()
})
it('flags a blank choice and a duplicate once per row, after trimming, case-sensitively', () => {
const errors = validateField(
field({
type: 'select',
options: ['Talk', 'Workshop', 'Meetup', ' ', ' Talk ', 'talk'],
}),
[],
)
expect(errors.choices).toEqual({
3: "Choices can't be blank.",
4: 'Two choices are the same.',
})
})
})
describe('problem counting', () => {
it('counts the three type-level inputs', () => {
const errors = validateForm(form({ name: '', key: 'e', fields: [] }))
expect(countProblems(errors)).toBe(3)
})
it('counts a field once whatever the number of rules it breaks', () => {
const noLabel = createField(1, { label: '', key: '' })
const inverted = createField(2, {
label: 'N',
key: 'n',
type: 'number',
min: '6',
max: '5',
})
const choices = createField(3, {
label: 'C',
key: 'c',
type: 'select',
options: ['A', '', 'A'],
})
expect(countProblems(validateForm(form({ fields: [noLabel, inverted, choices] })))).toBe(3)
})
it('counts one choice list with a blank and a duplicate as one problem', () => {
const choices = createField(1, {
label: 'C',
key: 'c',
type: 'select',
options: ['A', '', 'A'],
})
expect(countProblems(validateForm(form({ fields: [choices] })))).toBe(1)
})
it('counts one field with a bad key as one problem', () => {
const bad = createField(1, { label: 'S', key: 'start-date', keyMode: 'manual' })
expect(countProblems(validateForm(form({ fields: [bad] })))).toBe(1)
})
it('counts nothing for a valid form', () => {
expect(countProblems(validateForm(form()))).toBe(0)
})
})