feat: edit an existing custom type
This commit is contained in:
1 parent
083cc9e23e
commit
8e344b2500
57 files changed
+5648
-425
No files matched your search
@@ -0,0 +1,413 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
baselineSignatures,
|
||||
causeText,
|
||||
changeText,
|
||||
changeTitle,
|
||||
diffFields,
|
||||
flaggedChanges,
|
||||
isFieldChanged,
|
||||
summaryLine,
|
||||
} from '@/lib/schemaBuilderDiff'
|
||||
import type { ContentType } from '@/composables/useContentTypes'
|
||||
import { baselinePayload, formFromType } from '@/lib/schemaBuilderLoad'
|
||||
import { createField, type BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import { eventsFields } from './schemaFixtures'
|
||||
|
||||
const events: ContentType = {
|
||||
id: 1,
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
icon: null,
|
||||
fields: eventsFields,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
}
|
||||
|
||||
const baseline = baselinePayload(events)
|
||||
const signatures = baselineSignatures(baseline)
|
||||
|
||||
function loaded() {
|
||||
let next = 1
|
||||
return formFromType(events, () => next++).fields
|
||||
}
|
||||
|
||||
function edit(fields: BuilderField[], key: string, patch: Partial<BuilderField>) {
|
||||
return fields.map((field) => (field.key === key ? { ...field, ...patch } : field))
|
||||
}
|
||||
|
||||
function only(fields: BuilderField[]) {
|
||||
return diffFields(baseline, fields)
|
||||
}
|
||||
|
||||
describe('change detection per saved field', () => {
|
||||
it('marks nothing on the loaded fields', () => {
|
||||
expect(loaded().some((field) => isFieldChanged(field, signatures))).toBe(false)
|
||||
expect(only(loaded())).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Capacity', 'capacity', { max: '2000' }, { max: '5000' }],
|
||||
['Title', 'title', { label: 'Name of event' }, { label: 'Title' }],
|
||||
['Starts on', 'starts_on', { required: false }, { required: true }],
|
||||
[
|
||||
'Category',
|
||||
'category',
|
||||
{ options: ['Talk', 'Workshop', 'Meetup', 'Panel'] },
|
||||
{ options: ['Talk', 'Workshop', 'Meetup'] },
|
||||
],
|
||||
['Online', 'online', { defaultOn: true }, { defaultOn: false }],
|
||||
])(
|
||||
'marks %s changed, then clears it when the value is restored',
|
||||
(_, key, edited, original) => {
|
||||
const changed = edit(loaded(), key, edited)
|
||||
expect(
|
||||
changed
|
||||
.filter((field) => isFieldChanged(field, signatures))
|
||||
.map((field) => field.key),
|
||||
).toEqual([key])
|
||||
|
||||
const restored = edit(changed, key, original)
|
||||
expect(restored.some((field) => isFieldChanged(field, signatures))).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
it('does not count a reorder as a change of any field', () => {
|
||||
const [first, ...rest] = loaded()
|
||||
const reordered = [...rest, first!]
|
||||
|
||||
expect(reordered.some((field) => isFieldChanged(field, signatures))).toBe(false)
|
||||
expect(only(reordered)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not count surrounding spaces in a label', () => {
|
||||
expect(only(edit(loaded(), 'title', { label: ' Title ' }))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('review rows', () => {
|
||||
function row(fields: BuilderField[]) {
|
||||
const [change] = only(fields)
|
||||
return {
|
||||
title: changeTitle(change!),
|
||||
text: changeText(change!),
|
||||
flag: change!.flag,
|
||||
}
|
||||
}
|
||||
|
||||
const added = (patch: Partial<BuilderField>) => [
|
||||
...loaded(),
|
||||
createField(100, { label: 'New', key: 'new', ...patch }),
|
||||
]
|
||||
|
||||
it.each([
|
||||
[
|
||||
'adds an optional Text',
|
||||
added({ label: 'Speaker', key: 'speaker' }),
|
||||
'Add Speaker',
|
||||
'Text, optional.',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'adds a required Choice with a default',
|
||||
added({
|
||||
label: 'Room',
|
||||
key: 'room',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: ['Main hall', 'Studio'],
|
||||
defaultValue: 'Main hall',
|
||||
}),
|
||||
'Add Room',
|
||||
'Choice, required, default "Main hall". Existing entries get the default, so they still fit.',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'adds a required Number with no default',
|
||||
added({
|
||||
label: 'Seats',
|
||||
key: 'seats',
|
||||
type: 'number',
|
||||
required: true,
|
||||
}),
|
||||
'Add Seats',
|
||||
'Number, required. Existing entries have no value for it.',
|
||||
'mayNotFit',
|
||||
],
|
||||
[
|
||||
'lowers the highest value',
|
||||
edit(loaded(), 'capacity', { max: '2000' }),
|
||||
'Change Capacity',
|
||||
'Highest value goes from 5000 to 2000.',
|
||||
'mayNotFit',
|
||||
],
|
||||
[
|
||||
'raises the lowest value',
|
||||
edit(loaded(), 'capacity', { min: '10' }),
|
||||
'Change Capacity',
|
||||
'Lowest value goes from 1 to 10.',
|
||||
'mayNotFit',
|
||||
],
|
||||
[
|
||||
'sets a first longest',
|
||||
edit(loaded(), 'description', { max: '80' }),
|
||||
'Change Description',
|
||||
'Longest is now 80.',
|
||||
'mayNotFit',
|
||||
],
|
||||
[
|
||||
'turns Required on',
|
||||
edit(loaded(), 'ends_on', { required: true }),
|
||||
'Change Ends on',
|
||||
'Now required.',
|
||||
'mayNotFit',
|
||||
],
|
||||
[
|
||||
'turns Unique on',
|
||||
edit(loaded(), 'category', { unique: true }),
|
||||
'Change Category',
|
||||
'Values must now be unique.',
|
||||
'mayNotFit',
|
||||
],
|
||||
[
|
||||
'removes a choice',
|
||||
edit(loaded(), 'category', { options: ['Talk', 'Workshop'] }),
|
||||
'Change Category',
|
||||
'Choice removed: "Meetup".',
|
||||
'mayNotFit',
|
||||
],
|
||||
[
|
||||
'loosens the highest value',
|
||||
edit(loaded(), 'capacity', { max: '8000' }),
|
||||
'Change Capacity',
|
||||
'Highest value goes from 5000 to 8000.',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'renames a label',
|
||||
edit(loaded(), 'title', { label: 'Name' }),
|
||||
'Change Title',
|
||||
'Label goes from "Title" to "Name".',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'adds a choice',
|
||||
edit(loaded(), 'category', {
|
||||
options: ['Talk', 'Workshop', 'Meetup', 'Panel'],
|
||||
}),
|
||||
'Change Category',
|
||||
'Choice added: "Panel".',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'removes a field',
|
||||
loaded().filter((field) => field.key !== 'description'),
|
||||
'Remove Description',
|
||||
'Entries stop returning its values.',
|
||||
'removesData',
|
||||
],
|
||||
])('%s', (_, fields, title, text, flag) => {
|
||||
expect(row(fields)).toEqual({ title, text, flag })
|
||||
})
|
||||
|
||||
it('describes a default, an unset bound and a reordered choice list', () => {
|
||||
expect(row(edit(loaded(), 'capacity', { defaultValue: '20' })).text).toBe(
|
||||
'Default value is now "20".',
|
||||
)
|
||||
expect(row(edit(loaded(), 'capacity', { max: '' })).text).toBe(
|
||||
'Highest value is no longer set.',
|
||||
)
|
||||
expect(
|
||||
row(
|
||||
edit(loaded(), 'category', {
|
||||
options: ['Meetup', 'Talk', 'Workshop'],
|
||||
}),
|
||||
).text,
|
||||
).toBe('Choices were reordered.')
|
||||
expect(row(edit(loaded(), 'online', { defaultOn: true })).text).toBe(
|
||||
'On by default is now on.',
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'loosens the lowest value',
|
||||
'capacity',
|
||||
{ min: '0' },
|
||||
'Lowest value goes from 1 to 0.',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'sets a first lowest value',
|
||||
'description',
|
||||
{ min: '2' },
|
||||
'Shortest is now 2.',
|
||||
'mayNotFit',
|
||||
],
|
||||
[
|
||||
'unsets the lowest value',
|
||||
'capacity',
|
||||
{ min: '' },
|
||||
'Lowest value is no longer set.',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'turns Unique off',
|
||||
'title',
|
||||
{ unique: false },
|
||||
'Values no longer need to be unique.',
|
||||
null,
|
||||
],
|
||||
['turns Required off', 'starts_on', { required: false }, 'No longer required.', null],
|
||||
['turns the default off', 'online', { defaultOn: false }, '', null],
|
||||
])('%s', (_, key, patch, text, flag) => {
|
||||
const fields = edit(loaded(), key, patch)
|
||||
if (text === '') {
|
||||
expect(only(fields)).toEqual([])
|
||||
return
|
||||
}
|
||||
expect(row(fields)).toMatchObject({ text, flag })
|
||||
})
|
||||
|
||||
it('says the default was removed', () => {
|
||||
const withDefault = { ...eventsFields[4]!, default: 20 }
|
||||
const form = formFromType({ ...events, fields: [withDefault] }, () => 1).fields
|
||||
const [removed] = diffFields(
|
||||
baselinePayload({ ...events, fields: [withDefault] }),
|
||||
edit(form, 'capacity', { defaultValue: '' }),
|
||||
)
|
||||
|
||||
expect(changeText(removed!)).toBe('Default value removed.')
|
||||
expect(removed!.flag).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'starts_on',
|
||||
{ max: '2027-01-01' },
|
||||
'Latest date is now 2027-01-01. An entry may have a later date.',
|
||||
],
|
||||
[
|
||||
'starts_on',
|
||||
{ min: '2027-01-01' },
|
||||
'Earliest date is now 2027-01-01. An entry may have an earlier date.',
|
||||
],
|
||||
['description', { max: '80' }, 'Longest is now 80. An entry may be longer.'],
|
||||
['description', { min: '3' }, 'Shortest is now 3. An entry may be shorter.'],
|
||||
[
|
||||
'capacity',
|
||||
{ min: '10' },
|
||||
'Lowest value goes from 1 to 10. An entry may have a lower value.',
|
||||
],
|
||||
[
|
||||
'capacity',
|
||||
{ max: '2000' },
|
||||
'Highest value goes from 5000 to 2000. An entry may have a higher value.',
|
||||
],
|
||||
])('explains the tighter bound of %s with its hint', (key, patch, cause) => {
|
||||
const [change] = flaggedChanges(only(edit(loaded(), key, patch)))
|
||||
|
||||
expect(causeText(change!)).toBe(cause)
|
||||
})
|
||||
|
||||
it('reports a choice removed together with its hint', () => {
|
||||
const [change] = flaggedChanges(
|
||||
only(edit(loaded(), 'category', { options: ['Talk', 'Workshop'] })),
|
||||
)
|
||||
|
||||
expect(causeText(change!)).toBe('Choice removed: "Meetup". An entry may still use it.')
|
||||
})
|
||||
|
||||
it('summarises a lone removal and one added field alongside a change', () => {
|
||||
const base = loaded().filter((field) => field.key !== 'description')
|
||||
|
||||
expect(summaryLine(only(base))).toBe('Remove 1')
|
||||
expect(summaryLine(only(edit(base, 'capacity', { max: '2000' })))).toBe(
|
||||
'Change 1, remove 1',
|
||||
)
|
||||
})
|
||||
|
||||
it('describes an added field with a boolean or text default', () => {
|
||||
const added = (patch: Partial<BuilderField>) =>
|
||||
createField(100, { label: 'New', key: 'new', ...patch })
|
||||
|
||||
expect(row([...loaded(), added({ type: 'boolean', defaultOn: true })]).text).toBe(
|
||||
'Yes or no, optional, default on.',
|
||||
)
|
||||
expect(row([...loaded(), added({ defaultValue: 'x' })]).text).toBe(
|
||||
'Text, optional, default "x".',
|
||||
)
|
||||
})
|
||||
|
||||
it('joins several notes of one field and flags it when one of them is flagged', () => {
|
||||
const [change] = only(edit(loaded(), 'capacity', { max: '2000', label: 'Seats' }))
|
||||
|
||||
expect(changeText(change!)).toBe(
|
||||
'Label goes from "Capacity" to "Seats". Highest value goes from 5000 to 2000.',
|
||||
)
|
||||
expect(change!.flag).toBe('mayNotFit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('order of the review and the summary', () => {
|
||||
const added = [
|
||||
createField(100, { label: 'Speaker', key: 'speaker' }),
|
||||
createField(101, { label: 'Room', key: 'room' }),
|
||||
]
|
||||
const fields = edit(
|
||||
[...loaded().filter((field) => field.key !== 'description'), ...added],
|
||||
'capacity',
|
||||
{ max: '2000' },
|
||||
)
|
||||
|
||||
it('lists additions, changes then removals', () => {
|
||||
expect(only(fields).map(changeTitle)).toEqual([
|
||||
'Add Speaker',
|
||||
'Add Room',
|
||||
'Change Capacity',
|
||||
'Remove Description',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['2 added, 1 changed, 1 removed', 'Add 2 fields, change 1, remove 1'],
|
||||
['1 added', 'Add 1 field'],
|
||||
['1 changed, 2 removed', 'Change 1, remove 2'],
|
||||
['1 changed', 'Change 1'],
|
||||
])('writes the summary for %s', (changes, summary) => {
|
||||
const base = loaded()
|
||||
const wanted: Record<string, BuilderField[]> = {
|
||||
'2 added, 1 changed, 1 removed': fields,
|
||||
'1 added': [...base, added[0]!],
|
||||
'1 changed, 2 removed': edit(
|
||||
base.filter((field) => field.key !== 'description' && field.key !== 'ends_on'),
|
||||
'capacity',
|
||||
{ max: '2000' },
|
||||
),
|
||||
'1 changed': edit(base, 'capacity', { max: '2000' }),
|
||||
}
|
||||
|
||||
expect(summaryLine(only(wanted[changes]!))).toBe(summary)
|
||||
})
|
||||
|
||||
it('keeps only the flagged additions and changes for the refusal', () => {
|
||||
const flagged = flaggedChanges(only(fields))
|
||||
|
||||
expect(flagged.map(changeTitle)).toEqual(['Change Capacity'])
|
||||
expect(causeText(flagged[0]!)).toBe(
|
||||
'Highest value goes from 5000 to 2000. An entry may have a higher value.',
|
||||
)
|
||||
})
|
||||
|
||||
it('names the cause of a field added with no default without a hint', () => {
|
||||
const seats = createField(100, {
|
||||
label: 'Seats',
|
||||
key: 'seats',
|
||||
type: 'number',
|
||||
required: true,
|
||||
})
|
||||
const [change] = flaggedChanges(only([...loaded(), seats]))
|
||||
|
||||
expect(causeText(change!)).toBe('Number, required. Existing entries have no value for it.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ContentType } from '@/composables/useContentTypes'
|
||||
import { baselinePayload, fieldFromApi, formFromType } from '@/lib/schemaBuilderLoad'
|
||||
import { buildUpdatePayload, payloadField } from '@/lib/schemaBuilderPayload'
|
||||
import { apiField, eventsFields } from './schemaFixtures'
|
||||
|
||||
const events: ContentType = {
|
||||
id: 1,
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
icon: 'calendar',
|
||||
fields: eventsFields,
|
||||
created_at: '2026-09-01T09:30:00Z',
|
||||
updated_at: '2026-10-15T09:30:00Z',
|
||||
}
|
||||
|
||||
function counter() {
|
||||
let next = 1
|
||||
return () => next++
|
||||
}
|
||||
|
||||
describe('form from a content type', () => {
|
||||
it('locks the key, marks every field saved and keeps the saved order', () => {
|
||||
const form = formFromType(events, counter())
|
||||
|
||||
expect(form.name).toBe('Events')
|
||||
expect(form.key).toBe('events')
|
||||
expect(form.keyLocked).toBe(true)
|
||||
expect(form.fields.map((field) => field.key)).toEqual(
|
||||
eventsFields.map((field) => field.key),
|
||||
)
|
||||
expect(form.fields.every((field) => field.saved && field.keyMode === 'manual')).toBe(true)
|
||||
})
|
||||
|
||||
it('reuses the given uids by position, then draws new ones', () => {
|
||||
const form = formFromType(events, counter(), [10, 11])
|
||||
|
||||
expect(form.fields.map((field) => field.uid)).toEqual([10, 11, 1, 2, 3, 4, 5])
|
||||
})
|
||||
|
||||
it('maps each API field to its builder fields', () => {
|
||||
const [title, startsOn, , online, capacity, category] = formFromType(
|
||||
events,
|
||||
counter(),
|
||||
).fields
|
||||
|
||||
expect(title).toMatchObject({
|
||||
required: true,
|
||||
unique: true,
|
||||
max: '80',
|
||||
min: '',
|
||||
})
|
||||
expect(startsOn).toMatchObject({ type: 'date', required: true })
|
||||
expect(online).toMatchObject({
|
||||
type: 'boolean',
|
||||
defaultOn: false,
|
||||
defaultValue: '',
|
||||
})
|
||||
expect(capacity).toMatchObject({ min: '1', max: '5000' })
|
||||
expect(category).toMatchObject({
|
||||
options: ['Talk', 'Workshop', 'Meetup'],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a number default', apiField('N', 'n', 'number', { default: 0, options: [] }), '0'],
|
||||
['a text default', apiField('T', 't', 'text', { default: 'x', options: [] }), 'x'],
|
||||
[
|
||||
'a date default as a plain day',
|
||||
apiField('D', 'd', 'date', {
|
||||
default: '2026-11-21T00:00:00Z',
|
||||
options: [],
|
||||
}),
|
||||
'2026-11-21',
|
||||
],
|
||||
])('reads %s', (_, source, expected) => {
|
||||
expect(fieldFromApi(source, 1).defaultValue).toBe(expected)
|
||||
})
|
||||
|
||||
it('turns a true boolean default on and anything else off', () => {
|
||||
expect(fieldFromApi(apiField('B', 'b', 'boolean', { default: true }), 1).defaultOn).toBe(
|
||||
true,
|
||||
)
|
||||
expect(fieldFromApi(apiField('B', 'b', 'boolean', { default: false }), 1).defaultOn).toBe(
|
||||
false,
|
||||
)
|
||||
expect(fieldFromApi(apiField('B', 'b', 'boolean'), 1).defaultOn).toBe(false)
|
||||
})
|
||||
|
||||
it('reads date bounds as plain days and Choice options as text', () => {
|
||||
const date = fieldFromApi(
|
||||
apiField('D', 'd', 'date', {
|
||||
min: '2026-01-01T00:00:00Z',
|
||||
max: '2027-12-31',
|
||||
options: [],
|
||||
}),
|
||||
1,
|
||||
)
|
||||
|
||||
expect(date).toMatchObject({ min: '2026-01-01', max: '2027-12-31' })
|
||||
expect(fieldFromApi(apiField('C', 'c', 'select', { options: null }), 1).options).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the round trip', () => {
|
||||
it('sends back the loaded fields with no spurious difference', () => {
|
||||
const form = formFromType(events, counter())
|
||||
|
||||
const payload = buildUpdatePayload(form, events.icon)
|
||||
|
||||
expect(payload.fields).toEqual(
|
||||
eventsFields.map((field) => ({
|
||||
...field,
|
||||
default: field.type === 'boolean' ? null : field.default,
|
||||
})),
|
||||
)
|
||||
expect(payload.fields).toEqual(baselinePayload(events))
|
||||
})
|
||||
|
||||
it('has the same payload for a field and for its reloaded copy', () => {
|
||||
const field = fieldFromApi(eventsFields[4]!, 1)
|
||||
|
||||
expect(payloadField(field)).toEqual(payloadField(fieldFromApi(eventsFields[4]!, 2)))
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createField, type BuilderForm } from '@/lib/schemaBuilderModel'
|
||||
import { buildPayload } from '@/lib/schemaBuilderPayload'
|
||||
import { buildPayload, buildUpdatePayload } from '@/lib/schemaBuilderPayload'
|
||||
|
||||
const EVENTS: BuilderForm = {
|
||||
name: ' Events ',
|
||||
key: 'events',
|
||||
keyMode: 'auto',
|
||||
keyLocked: false,
|
||||
fields: [
|
||||
createField(1, { label: 'Title', key: 'title', required: true, unique: true, max: '80' }),
|
||||
createField(1, {
|
||||
label: 'Title',
|
||||
key: 'title',
|
||||
required: true,
|
||||
unique: true,
|
||||
max: '80',
|
||||
}),
|
||||
createField(2, {
|
||||
label: 'Starts on',
|
||||
key: 'starts_on',
|
||||
@@ -16,7 +23,12 @@ const EVENTS: BuilderForm = {
|
||||
min: '2026-01-01',
|
||||
max: '2027-12-31',
|
||||
}),
|
||||
createField(3, { label: 'Online', key: 'online', type: 'boolean', defaultOn: true }),
|
||||
createField(3, {
|
||||
label: 'Online',
|
||||
key: 'online',
|
||||
type: 'boolean',
|
||||
defaultOn: true,
|
||||
}),
|
||||
createField(4, {
|
||||
label: 'Capacity',
|
||||
key: 'capacity',
|
||||
@@ -32,12 +44,24 @@ const EVENTS: BuilderForm = {
|
||||
defaultValue: 'Talk',
|
||||
options: [' Talk', 'Workshop ', 'Meetup'],
|
||||
}),
|
||||
createField(6, { label: 'Description', key: 'description', type: 'richtext', max: '5000' }),
|
||||
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 }
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
type: 'text',
|
||||
required: false,
|
||||
unique: false,
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
describe('create payload', () => {
|
||||
@@ -146,7 +170,14 @@ describe('create payload', () => {
|
||||
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' })],
|
||||
fields: [
|
||||
createField(1, {
|
||||
label: 'T',
|
||||
key: 't',
|
||||
unique: true,
|
||||
defaultValue: 'Draft',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
expect(body.fields[0]!.default).toBeNull()
|
||||
@@ -156,8 +187,18 @@ describe('create payload', () => {
|
||||
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 }),
|
||||
createField(1, {
|
||||
label: 'R',
|
||||
key: 'r',
|
||||
type: 'richtext',
|
||||
unique: true,
|
||||
}),
|
||||
createField(2, {
|
||||
label: 'B',
|
||||
key: 'b',
|
||||
type: 'boolean',
|
||||
unique: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -168,8 +209,20 @@ describe('create payload', () => {
|
||||
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'] }),
|
||||
createField(1, {
|
||||
label: 'B',
|
||||
key: 'b',
|
||||
type: 'boolean',
|
||||
min: '1',
|
||||
max: '2',
|
||||
}),
|
||||
createField(2, {
|
||||
label: 'S',
|
||||
key: 's',
|
||||
type: 'select',
|
||||
min: '1',
|
||||
options: ['A'],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -188,3 +241,18 @@ describe('create payload', () => {
|
||||
expect(body.fields[0]!.min).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildUpdatePayload', () => {
|
||||
it('sends exactly name, icon and fields, with no key and the name trimmed', () => {
|
||||
const payload = buildUpdatePayload(EVENTS, 'calendar')
|
||||
|
||||
expect(Object.keys(payload)).toEqual(['name', 'icon', 'fields'])
|
||||
expect(payload.name).toBe('Events')
|
||||
expect(payload.icon).toBe('calendar')
|
||||
expect(payload.fields).toEqual(buildPayload(EVENTS).fields)
|
||||
})
|
||||
|
||||
it('keeps a null icon null', () => {
|
||||
expect(buildUpdatePayload(EVENTS, null).icon).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createField } from '@/lib/schemaBuilderModel'
|
||||
import { reservedKeys, restoreIndex } from '@/lib/schemaBuilderRemoval'
|
||||
|
||||
const keys = ['title', 'starts_on', 'ends_on', 'online', 'capacity', 'category', 'description']
|
||||
const saved = (key: string) => createField(keys.indexOf(key), { key, saved: true })
|
||||
|
||||
describe('where an undo puts a field back', () => {
|
||||
it('restores the last field at the end', () => {
|
||||
const current = keys.slice(0, 6).map(saved)
|
||||
|
||||
expect(restoreIndex(current, keys, 'description')).toBe(6)
|
||||
})
|
||||
|
||||
it('restores a middle field after the saved field that came before it', () => {
|
||||
const current = ['title', 'starts_on', 'online', 'capacity', 'category', 'description'].map(
|
||||
saved,
|
||||
)
|
||||
|
||||
expect(restoreIndex(current, keys, 'ends_on')).toBe(2)
|
||||
})
|
||||
|
||||
it('restores the first field at the top', () => {
|
||||
const current = keys.slice(1).map(saved)
|
||||
|
||||
expect(restoreIndex(current, keys, 'title')).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores fields added since the last save when looking for the slot', () => {
|
||||
const current = [saved('title'), createField(99, { key: 'speaker' }), saved('online')]
|
||||
|
||||
expect(restoreIndex(current, keys, 'ends_on')).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the keys of the removed fields reserved', () => {
|
||||
expect(
|
||||
reservedKeys([{ field: saved('description') }, { field: saved('ends_on') }]),
|
||||
).toEqual(['description', 'ends_on'])
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,7 @@ function form(overrides: Partial<BuilderForm> = {}): BuilderForm {
|
||||
name: 'Events',
|
||||
key: 'events',
|
||||
keyMode: 'auto',
|
||||
keyLocked: false,
|
||||
fields: [starterField(1)],
|
||||
...overrides,
|
||||
}
|
||||
@@ -109,8 +110,16 @@ describe('field validation', () => {
|
||||
})
|
||||
|
||||
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 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] }))
|
||||
|
||||
@@ -162,7 +171,11 @@ describe('field validation', () => {
|
||||
],
|
||||
[
|
||||
'date',
|
||||
{ min: '2026-01-01', max: '2027-12-31', defaultValue: '2028-01-01' },
|
||||
{
|
||||
min: '2026-01-01',
|
||||
max: '2027-12-31',
|
||||
defaultValue: '2028-01-01',
|
||||
},
|
||||
'default',
|
||||
'The default value is outside the limits.',
|
||||
],
|
||||
@@ -238,7 +251,11 @@ describe('problem counting', () => {
|
||||
})
|
||||
|
||||
it('counts one field with a bad key as one problem', () => {
|
||||
const bad = createField(1, { label: 'S', key: 'start-date', keyMode: 'manual' })
|
||||
const bad = createField(1, {
|
||||
label: 'S',
|
||||
key: 'start-date',
|
||||
keyMode: 'manual',
|
||||
})
|
||||
|
||||
expect(countProblems(validateForm(form({ fields: [bad] })))).toBe(1)
|
||||
})
|
||||
@@ -247,3 +264,45 @@ describe('problem counting', () => {
|
||||
expect(countProblems(validateForm(form()))).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validation of a saved type', () => {
|
||||
const saved = (key: string, overrides: Partial<BuilderField> = {}) =>
|
||||
createField(1, {
|
||||
label: key,
|
||||
key,
|
||||
keyMode: 'manual',
|
||||
saved: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('shows no key error for a locked type key', () => {
|
||||
expect(validateType(form({ key: 'E', keyLocked: true })).key).toBeUndefined()
|
||||
expect(Object.keys(validateType(form({ key: 'E', keyLocked: true })))).toEqual([])
|
||||
})
|
||||
|
||||
it('never shows a key error on a saved field', () => {
|
||||
const odd = saved('Old-Key')
|
||||
|
||||
expect(validateForm(form({ fields: [odd] })).fields.size).toBe(0)
|
||||
})
|
||||
|
||||
it('flags a new field whose key is a saved key, whatever the order', () => {
|
||||
const copy = createField(2, { label: 'Title', key: 'title' })
|
||||
const errors = validateForm(form({ fields: [copy, saved('title')] }))
|
||||
|
||||
expect(errors.fields.get(2)?.key).toBe('Another field already uses this key.')
|
||||
expect(errors.fields.has(1)).toBe(false)
|
||||
})
|
||||
|
||||
it('flags a new field that takes the key of a removed field', () => {
|
||||
const fresh = createField(2, {
|
||||
label: 'Description',
|
||||
key: 'description',
|
||||
})
|
||||
|
||||
expect(validateForm(form({ fields: [fresh] }), ['description']).fields.get(2)?.key).toBe(
|
||||
'Another field already uses this key.',
|
||||
)
|
||||
expect(validateForm(form({ fields: [fresh] })).fields.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { entriesNotice } from '@/lib/schemaEntries'
|
||||
|
||||
describe('the entries notice', () => {
|
||||
it.each([
|
||||
[-3, ''],
|
||||
[0, ''],
|
||||
[1, '1 entry uses this content type. Changes are checked against it when you save.'],
|
||||
[37, '37 entries use this content type. Changes are checked against them when you save.'],
|
||||
[
|
||||
1200,
|
||||
'1,200 entries use this content type. Changes are checked against them when you save.',
|
||||
],
|
||||
])('reads for %s entries', (total, copy) => {
|
||||
expect(entriesNotice(total)).toBe(copy)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
export function apiField(label: string, key: string, type: string, rest: object = {}) {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
type,
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
options: null,
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
export const eventsFields = [
|
||||
apiField('Title', 'title', 'text', {
|
||||
required: true,
|
||||
unique: true,
|
||||
max: 80,
|
||||
options: [],
|
||||
}),
|
||||
apiField('Starts on', 'starts_on', 'date', { required: true, options: [] }),
|
||||
apiField('Ends on', 'ends_on', 'date', { options: [] }),
|
||||
apiField('Online', 'online', 'boolean', { default: false, options: [] }),
|
||||
apiField('Capacity', 'capacity', 'number', {
|
||||
min: 1,
|
||||
max: 5000,
|
||||
options: [],
|
||||
}),
|
||||
apiField('Category', 'category', 'select', {
|
||||
options: ['Talk', 'Workshop', 'Meetup'],
|
||||
}),
|
||||
apiField('Description', 'description', 'richtext', { options: [] }),
|
||||
]
|
||||
|
||||
export const recipesFields = [
|
||||
apiField('Title', 'title', 'text', { required: true, options: [] }),
|
||||
apiField('Servings', 'servings', 'number', { min: 1, options: [] }),
|
||||
apiField('Vegetarian', 'vegetarian', 'boolean', { options: [] }),
|
||||
apiField('Method', 'method', 'richtext', { options: [] }),
|
||||
]
|
||||
Reference in new issue
Block a user