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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user