feat: schema builder's create flow
This commit is contained in:
1 parent
f514146fed
commit
083cc9e23e
71 files changed
+7223
-61
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const HOME_PATH = '/home'
|
||||
export const SCHEMA_PATH = '/schema'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'],
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
Reference in new issue
Block a user