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)
})
})
+9
View File
@@ -0,0 +1,9 @@
export function moveItem<T>(items: T[], from: number, to: number) {
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {
return items
}
const next = [...items]
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved!)
return next
}
-10
View File
@@ -14,16 +14,6 @@ export function metaSummary(authors: Byline[], siteName: string | null) {
return siteName ? t('{0} · {1}', [siteName, summary]) : summary
}
export function moveItem<T>(items: T[], from: number, to: number) {
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {
return items
}
const next = [...items]
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved!)
return next
}
export function insertionTarget(from: number, gap: number) {
return gap > from ? gap - 1 : gap
}
+2
View File
@@ -0,0 +1,2 @@
export const HOME_PATH = '/home'
export const SCHEMA_PATH = '/schema'
+35
View File
@@ -0,0 +1,35 @@
import { BUILDER_LIMITS } from '@/lib/schemaBuilderModel'
function plain(text: string) {
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
}
export function deriveTypeKey(name: string) {
const slug = plain(name)
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return slug.slice(0, BUILDER_LIMITS.typeKeyMax).replace(/-+$/, '')
}
function fieldKeyBase(label: string) {
return plain(label)
.replace(/[^a-z0-9]+/g, '_')
.replace(/^[^a-z]+/, '')
.slice(0, BUILDER_LIMITS.fieldKeyMax)
.replace(/_+$/, '')
}
export function deriveFieldKey(label: string, taken: readonly string[]) {
const base = fieldKeyBase(label)
if (!base || !taken.includes(base)) {
return base
}
for (let attempt = 2; ; attempt++) {
const suffix = `_${attempt}`
const candidate =
base.slice(0, BUILDER_LIMITS.fieldKeyMax - suffix.length).replace(/_+$/, '') + suffix
if (!taken.includes(candidate)) {
return candidate
}
}
}
+127
View File
@@ -0,0 +1,127 @@
export const FIELD_TYPES = ['text', 'richtext', 'number', 'boolean', 'date', 'select'] as const
export type FieldType = (typeof FIELD_TYPES)[number]
export type KeyMode = 'auto' | 'manual'
export type KeyDisplayMode = KeyMode | 'empty'
export type BuilderStatus = 'saving' | 'offline' | 'failed' | 'problems' | 'dirty' | 'none'
export const BUILDER_LIMITS = {
nameMax: 100,
typeKeyMin: 2,
typeKeyMax: 40,
fieldsMax: 50,
fieldKeyMax: 40,
choicesMax: 50,
}
export const RESERVED_TYPE_KEYS = ['new']
export const PREVIEW_KEY_FALLBACK = 'events'
export const STARTER_LABEL = 'Title'
export const STARTER_KEY = 'title'
export interface BuilderField {
uid: number
label: string
key: string
keyMode: KeyMode
type: FieldType
required: boolean
unique: boolean
defaultValue: string
defaultOn: boolean
min: string
max: string
options: string[]
}
export interface BuilderForm {
name: string
key: string
keyMode: KeyMode
fields: BuilderField[]
}
export function keyDisplayMode(mode: KeyMode, key: string): KeyDisplayMode {
if (mode === 'manual') {
return 'manual'
}
return key ? 'auto' : 'empty'
}
export function createField(uid: number, overrides: Partial<BuilderField> = {}): BuilderField {
return {
uid,
label: '',
key: '',
keyMode: 'auto',
type: 'text',
required: false,
unique: false,
defaultValue: '',
defaultOn: false,
min: '',
max: '',
options: [],
...overrides,
}
}
export function starterField(uid: number): BuilderField {
return createField(uid, { label: STARTER_LABEL, key: STARTER_KEY })
}
export function blankForm(uid: number): BuilderForm {
return { name: '', key: '', keyMode: 'auto', fields: [starterField(uid)] }
}
export function changeFieldType(field: BuilderField, type: FieldType): BuilderField {
if (field.type === type) {
return field
}
return createField(field.uid, {
label: field.label,
key: field.key,
keyMode: field.keyMode,
required: field.required,
type,
})
}
export function supportsUnique(type: FieldType) {
return type !== 'richtext' && type !== 'boolean'
}
export function supportsLimits(type: FieldType) {
return type === 'text' || type === 'richtext' || type === 'number' || type === 'date'
}
export function hasDefaultValue(field: BuilderField) {
return !field.unique && field.defaultValue !== ''
}
export function trimmedOptions(field: BuilderField) {
return field.options.map((option) => option.trim())
}
export function clearStaleDefault(field: BuilderField): BuilderField {
if (field.type !== 'select' || field.defaultValue === '') {
return field
}
return trimmedOptions(field).includes(field.defaultValue)
? field
: { ...field, defaultValue: '' }
}
export function isUnique(field: BuilderField) {
return field.unique && supportsUnique(field.type)
}
export function effectiveDefault(field: BuilderField) {
if (isUnique(field) || field.defaultValue === '') {
return ''
}
if (field.type === 'select') {
return trimmedOptions(field).includes(field.defaultValue) ? field.defaultValue : ''
}
return field.defaultValue
}
+66
View File
@@ -0,0 +1,66 @@
import {
effectiveDefault,
isUnique,
supportsLimits,
trimmedOptions,
type BuilderField,
type BuilderForm,
} from '@/lib/schemaBuilderModel'
export interface PayloadField {
key: string
label: string
type: string
required: boolean
unique: boolean
default: string | number | boolean | null
min: number | string | null
max: number | string | null
options: string[]
}
export interface ContentTypePayload {
key: string
name: string
fields: PayloadField[]
}
function limit(field: BuilderField, text: string) {
if (!supportsLimits(field.type) || text.trim() === '') {
return null
}
return field.type === 'date' ? text : Number(text)
}
function defaultFor(field: BuilderField) {
if (field.type === 'boolean') {
return field.defaultOn ? true : null
}
const given = effectiveDefault(field)
if (given === '') {
return null
}
return field.type === 'number' ? Number(given) : given
}
export function payloadField(field: BuilderField): PayloadField {
return {
key: field.key,
label: field.label.trim(),
type: field.type,
required: field.required,
unique: isUnique(field),
default: defaultFor(field),
min: limit(field, field.min),
max: limit(field, field.max),
options: field.type === 'select' ? trimmedOptions(field) : [],
}
}
export function buildPayload(form: BuilderForm): ContentTypePayload {
return {
key: form.key,
name: form.name.trim(),
fields: form.fields.map(payloadField),
}
}
+79
View File
@@ -0,0 +1,79 @@
import {
PREVIEW_KEY_FALLBACK,
effectiveDefault,
trimmedOptions,
type BuilderField,
} from '@/lib/schemaBuilderModel'
import { isValidFieldKey } from '@/lib/schemaBuilderValidation'
const SAMPLE_TEXT = 'Example text'
const SAMPLE_MARKDOWN = 'Markdown text'
const SAMPLE_DATE = '2026-11-21'
const SAMPLE_ID = 42
const SAMPLE_USER = 3
const SAMPLE_MOMENT = '2026-10-15T11:02:00Z'
const INDENT = 2
export function previewKey(key: string) {
return key === '' ? PREVIEW_KEY_FALLBACK : key
}
export function previewRoutes(key: string) {
const base = `/v1/content-type/${previewKey(key)}/entry`
const item = `${base}/{id}`
return [
{ method: 'GET', path: base },
{ method: 'POST', path: base },
{ method: 'GET', path: item },
{ method: 'PUT', path: item },
{ method: 'DELETE', path: item },
]
}
export function sampleValue(field: BuilderField): string | number | boolean | null {
const given = effectiveDefault(field)
switch (field.type) {
case 'richtext':
return given === '' ? SAMPLE_MARKDOWN : given
case 'number': {
const fromDefault = Number(given)
const fromMin = Number(field.min)
if (given !== '' && Number.isFinite(fromDefault)) {
return fromDefault
}
return field.min.trim() !== '' && Number.isFinite(fromMin) ? fromMin : 0
}
case 'boolean':
return field.defaultOn
case 'date':
return given === '' ? SAMPLE_DATE : given
case 'select': {
const choices = trimmedOptions(field).filter((option) => option !== '')
return given !== '' ? given : (choices[0] ?? null)
}
default:
return given === '' ? SAMPLE_TEXT : given
}
}
export function sampleEntry(key: string, fields: readonly BuilderField[]) {
const data: Record<string, string | number | boolean | null> = {}
for (const field of fields) {
if (isValidFieldKey(field.key)) {
data[field.key] = sampleValue(field)
}
}
return {
id: SAMPLE_ID,
content_type: previewKey(key),
data,
created_by: SAMPLE_USER,
updated_by: SAMPLE_USER,
created_at: SAMPLE_MOMENT,
updated_at: SAMPLE_MOMENT,
}
}
export function sampleJson(key: string, fields: readonly BuilderField[]) {
return JSON.stringify(sampleEntry(key, fields), null, INDENT)
}
+22
View File
@@ -0,0 +1,22 @@
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
export type TypeTouch = 'name' | 'key' | 'fields'
export type FieldTouch = 'label' | 'key' | 'min' | 'max' | 'default' | 'choices'
export function typeTouchKey(name: TypeTouch) {
return `type:${name}`
}
export function fieldTouchKey(uid: number, name: FieldTouch) {
return `field:${uid}:${name}`
}
export const FIELD_ERROR_TOUCH: readonly (readonly [keyof FieldErrors, FieldTouch])[] = [
['label', 'label'],
['key', 'key'],
['min', 'min'],
['max', 'max'],
['default', 'default'],
['options', 'choices'],
['choices', 'choices'],
]
+227
View File
@@ -0,0 +1,227 @@
import { t } from '@/i18n'
import {
BUILDER_LIMITS,
RESERVED_TYPE_KEYS,
trimmedOptions,
type BuilderField,
type BuilderForm,
} from '@/lib/schemaBuilderModel'
const TYPE_KEY_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
const FIELD_KEY_PATTERN = /^[a-z][a-z0-9_]*$/
const WHOLE_PATTERN = /^\d+$/
export interface TypeErrors {
name?: string
key?: string
fields?: string
}
export interface FieldErrors {
label?: string
key?: string
min?: string
max?: string
default?: string
options?: string
choices?: Record<number, string>
}
export interface FormErrors {
type: TypeErrors
fields: Map<number, FieldErrors>
}
export function isValidTypeKey(key: string) {
return (
key.length >= BUILDER_LIMITS.typeKeyMin &&
key.length <= BUILDER_LIMITS.typeKeyMax &&
TYPE_KEY_PATTERN.test(key)
)
}
export function isValidFieldKey(key: string) {
return key.length <= BUILDER_LIMITS.fieldKeyMax && FIELD_KEY_PATTERN.test(key)
}
export function validateType(form: BuilderForm): TypeErrors {
const errors: TypeErrors = {}
if (form.name.trim() === '') {
errors.name = t('Give the content type a name.')
} else if (form.name.length > BUILDER_LIMITS.nameMax) {
errors.name = t('Use 100 characters or fewer.')
}
if (RESERVED_TYPE_KEYS.includes(form.key)) {
errors.key = t('The key "{0}" is reserved. Choose a different key.', [form.key])
} else if (!isValidTypeKey(form.key)) {
errors.key = t('Use 2 to 40 lowercase letters, digits or hyphens.')
}
if (form.fields.length === 0) {
errors.fields = t('A content type needs at least one field.')
} else if (form.fields.length > BUILDER_LIMITS.fieldsMax) {
errors.fields = t('A content type can have up to 50 fields.')
}
return errors
}
function parseWhole(text: string) {
return WHOLE_PATTERN.test(text.trim()) ? Number(text.trim()) : null
}
function parseNumber(text: string) {
const trimmed = text.trim()
if (trimmed === '') {
return null
}
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : Number.NaN
}
function textLimits(field: BuilderField, errors: FieldErrors) {
let min: number | null = null
let max: number | null = null
if (field.min.trim() !== '') {
min = parseWhole(field.min)
if (min === null) {
errors.min = t('Use a whole number, 0 or more.')
}
}
if (field.max.trim() !== '') {
max = parseWhole(field.max)
if (max === null) {
errors.max = t('Use a whole number, 0 or more.')
}
}
if (min !== null && max !== null && min > max) {
errors.min = t("Shortest can't be longer than longest.")
}
if (field.defaultValue !== '' && !field.unique) {
const length = Array.from(field.defaultValue).length
if ((min !== null && length < min) || (max !== null && length > max)) {
errors.default = t('The default value is outside the limits.')
}
}
}
function numberLimits(field: BuilderField, errors: FieldErrors) {
const min = parseNumber(field.min)
const max = parseNumber(field.max)
if (Number.isNaN(min)) {
errors.min = t('Enter a number.')
}
if (Number.isNaN(max)) {
errors.max = t('Enter a number.')
}
if (min !== null && max !== null && min > max) {
errors.min = t("Lowest value can't be above the highest value.")
}
if (field.defaultValue !== '' && !field.unique) {
const value = parseNumber(field.defaultValue)
if (value === null || Number.isNaN(value)) {
errors.default = t('Enter a number.')
} else if (
(min !== null && !Number.isNaN(min) && value < min) ||
(max !== null && !Number.isNaN(max) && value > max)
) {
errors.default = t('The default value is outside the limits.')
}
}
}
function dateLimits(field: BuilderField, errors: FieldErrors) {
const min = field.min
const max = field.max
if (min !== '' && max !== '' && min > max) {
errors.min = t("Earliest date can't be after the latest date.")
}
if (field.defaultValue !== '' && !field.unique) {
if ((min !== '' && field.defaultValue < min) || (max !== '' && field.defaultValue > max)) {
errors.default = t('The default value is outside the limits.')
}
}
}
function choiceErrors(field: BuilderField, errors: FieldErrors) {
const options = trimmedOptions(field)
if (options.length === 0) {
errors.options = t('Add at least one choice.')
return
}
const seen = new Set<string>()
const rows: Record<number, string> = {}
options.forEach((option, index) => {
if (option === '') {
rows[index] = t("Choices can't be blank.")
} else if (seen.has(option)) {
rows[index] = t('Two choices are the same.')
} else {
seen.add(option)
}
})
if (Object.keys(rows).length > 0) {
errors.choices = rows
}
}
function keyError(field: BuilderField, earlierKeys: readonly string[]) {
if (field.keyMode === 'auto' && field.label.trim() === '') {
return undefined
}
if (!isValidFieldKey(field.key)) {
return t('Start with a letter. Use lowercase letters, digits and underscores.')
}
if (earlierKeys.includes(field.key)) {
return t('Another field already uses this key.')
}
return undefined
}
export function validateField(field: BuilderField, earlierKeys: readonly string[]): FieldErrors {
const errors: FieldErrors = {}
if (field.label.trim() === '') {
errors.label = t('Give the field a label.')
}
const key = keyError(field, earlierKeys)
if (key) {
errors.key = key
}
if (field.type === 'text' || field.type === 'richtext') {
textLimits(field, errors)
} else if (field.type === 'number') {
numberLimits(field, errors)
} else if (field.type === 'date') {
dateLimits(field, errors)
} else if (field.type === 'select') {
choiceErrors(field, errors)
}
return errors
}
export function hasFieldErrors(errors: FieldErrors) {
return Object.keys(errors).length > 0
}
export function validateForm(form: BuilderForm): FormErrors {
const fields = new Map<number, FieldErrors>()
const earlierKeys: string[] = []
for (const field of form.fields) {
const errors = validateField(field, earlierKeys)
if (hasFieldErrors(errors)) {
fields.set(field.uid, errors)
}
earlierKeys.push(field.key)
}
return { type: validateType(form), fields }
}
export function countErrors(errors: FormErrors) {
let total = Object.keys(errors.type).length
for (const fieldErrors of errors.fields.values()) {
total += Object.keys(fieldErrors).length
}
return total
}
export function countProblems(errors: FormErrors) {
return Object.keys(errors.type).length + errors.fields.size
}
+10
View File
@@ -0,0 +1,10 @@
export const BOTTOM_SHEET_UI = {
content:
'h-11/12 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
header: 'h-14 min-h-14 border-b border-default px-4 py-0 sm:px-4',
title: 'text-lg font-semibold text-highlighted',
description: 'sr-only',
close: 'top-4.5 end-2 size-11 justify-center p-0',
body: 'flex flex-col gap-4 p-4 sm:p-4',
footer: 'border-t border-default px-4 py-3 sm:px-4',
}