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: [] }),
|
||||
]
|
||||
@@ -0,0 +1,263 @@
|
||||
import { t } from '@/i18n'
|
||||
import { fieldTypeLabel } from '@/lib/schemaFields'
|
||||
import type { BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import { payloadField, type PayloadField } from '@/lib/schemaBuilderPayload'
|
||||
|
||||
export type ChangeKind = 'add' | 'change' | 'remove'
|
||||
export type ChangeFlag = 'mayNotFit' | 'removesData'
|
||||
|
||||
export interface ChangeNote {
|
||||
text: string
|
||||
hint: string
|
||||
flagged: boolean
|
||||
}
|
||||
|
||||
export interface FieldChange {
|
||||
kind: ChangeKind
|
||||
uid: number | null
|
||||
key: string
|
||||
label: string
|
||||
notes: ChangeNote[]
|
||||
flag: ChangeFlag | null
|
||||
}
|
||||
|
||||
type Bound = number | string | null
|
||||
|
||||
export function fieldSignature(field: PayloadField) {
|
||||
return JSON.stringify(field)
|
||||
}
|
||||
|
||||
export function isFieldChanged(field: BuilderField, baseline: ReadonlyMap<string, string>) {
|
||||
return field.saved && fieldSignature(payloadField(field)) !== baseline.get(field.key)
|
||||
}
|
||||
|
||||
export function baselineSignatures(fields: readonly PayloadField[]) {
|
||||
return new Map(fields.map((field) => [field.key, fieldSignature(field)]))
|
||||
}
|
||||
|
||||
function note(text: string, hint = '', flagged = false): ChangeNote {
|
||||
return { text, hint, flagged }
|
||||
}
|
||||
|
||||
function boundNames(type: string) {
|
||||
if (type === 'number') {
|
||||
return [t('Lowest value'), t('Highest value')] as const
|
||||
}
|
||||
if (type === 'date') {
|
||||
return [t('Earliest date'), t('Latest date')] as const
|
||||
}
|
||||
return [t('Shortest'), t('Longest')] as const
|
||||
}
|
||||
|
||||
function tightenHint(type: string, which: 'min' | 'max') {
|
||||
if (type === 'number') {
|
||||
return which === 'max'
|
||||
? t('An entry may have a higher value.')
|
||||
: t('An entry may have a lower value.')
|
||||
}
|
||||
if (type === 'date') {
|
||||
return which === 'max'
|
||||
? t('An entry may have a later date.')
|
||||
: t('An entry may have an earlier date.')
|
||||
}
|
||||
return which === 'max' ? t('An entry may be longer.') : t('An entry may be shorter.')
|
||||
}
|
||||
|
||||
function isTighter(which: 'min' | 'max', before: Bound, after: Bound) {
|
||||
if (after === null) {
|
||||
return false
|
||||
}
|
||||
if (before === null) {
|
||||
return true
|
||||
}
|
||||
return which === 'max' ? after < before : after > before
|
||||
}
|
||||
|
||||
function boundNote(type: string, which: 'min' | 'max', before: Bound, after: Bound) {
|
||||
if (before === after) {
|
||||
return []
|
||||
}
|
||||
const name = boundNames(type)[which === 'min' ? 0 : 1]
|
||||
if (after === null) {
|
||||
return [note(t('{0} is no longer set.', [name]))]
|
||||
}
|
||||
const tighter = isTighter(which, before, after)
|
||||
const hint = tighter ? tightenHint(type, which) : ''
|
||||
const text =
|
||||
before === null
|
||||
? t('{0} is now {1}.', [name, String(after)])
|
||||
: t('{0} goes from {1} to {2}.', [name, String(before), String(after)])
|
||||
return [note(text, hint, tighter)]
|
||||
}
|
||||
|
||||
function optionNotes(before: readonly string[], after: readonly string[]) {
|
||||
const removed = before.filter((option) => !after.includes(option))
|
||||
const added = after.filter((option) => !before.includes(option))
|
||||
const notes = [
|
||||
...removed.map((option) =>
|
||||
note(t('Choice removed: "{0}".', [option]), t('An entry may still use it.'), true),
|
||||
),
|
||||
...added.map((option) => note(t('Choice added: "{0}".', [option]))),
|
||||
]
|
||||
if (notes.length === 0 && before.join('\n') !== after.join('\n')) {
|
||||
notes.push(note(t('Choices were reordered.')))
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
function defaultNotes(before: PayloadField, after: PayloadField) {
|
||||
if (before.default === after.default) {
|
||||
return []
|
||||
}
|
||||
if (after.type === 'boolean') {
|
||||
return [
|
||||
note(
|
||||
after.default === true
|
||||
? t('On by default is now on.')
|
||||
: t('On by default is now off.'),
|
||||
),
|
||||
]
|
||||
}
|
||||
if (after.default === null) {
|
||||
return [note(t('Default value removed.'))]
|
||||
}
|
||||
return [note(t('Default value is now "{0}".', [String(after.default)]))]
|
||||
}
|
||||
|
||||
function changeNotes(before: PayloadField, after: PayloadField) {
|
||||
const notes: ChangeNote[] = []
|
||||
if (before.label !== after.label) {
|
||||
notes.push(note(t('Label goes from "{0}" to "{1}".', [before.label, after.label])))
|
||||
}
|
||||
if (before.required !== after.required) {
|
||||
notes.push(
|
||||
after.required
|
||||
? note(t('Now required.'), t('An entry may have no value.'), true)
|
||||
: note(t('No longer required.')),
|
||||
)
|
||||
}
|
||||
if (before.unique !== after.unique) {
|
||||
notes.push(
|
||||
after.unique
|
||||
? note(t('Values must now be unique.'), t('Entries may share a value.'), true)
|
||||
: note(t('Values no longer need to be unique.')),
|
||||
)
|
||||
}
|
||||
notes.push(...boundNote(after.type, 'min', before.min, after.min))
|
||||
notes.push(...boundNote(after.type, 'max', before.max, after.max))
|
||||
notes.push(...defaultNotes(before, after))
|
||||
notes.push(...optionNotes(before.options, after.options))
|
||||
return notes
|
||||
}
|
||||
|
||||
function addNote(field: PayloadField) {
|
||||
const parts = [fieldTypeLabel(field.type), field.required ? t('required') : t('optional')]
|
||||
if (field.default !== null) {
|
||||
parts.push(
|
||||
field.type === 'boolean'
|
||||
? t('default on')
|
||||
: t('default "{0}"', [String(field.default)]),
|
||||
)
|
||||
}
|
||||
const summary = `${parts.join(', ')}.`
|
||||
if (!field.required) {
|
||||
return note(summary)
|
||||
}
|
||||
return field.default === null
|
||||
? note(`${summary} ${t('Existing entries have no value for it.')}`, '', true)
|
||||
: note(`${summary} ${t('Existing entries get the default, so they still fit.')}`)
|
||||
}
|
||||
|
||||
function flagOf(notes: readonly ChangeNote[]): ChangeFlag | null {
|
||||
return notes.some((entry) => entry.flagged) ? 'mayNotFit' : null
|
||||
}
|
||||
|
||||
export function diffFields(
|
||||
baseline: readonly PayloadField[],
|
||||
fields: readonly BuilderField[],
|
||||
): FieldChange[] {
|
||||
const before = new Map(baseline.map((field) => [field.key, field]))
|
||||
const additions: FieldChange[] = []
|
||||
const changes: FieldChange[] = []
|
||||
for (const field of fields) {
|
||||
const after = payloadField(field)
|
||||
const previous = field.saved ? before.get(field.key) : undefined
|
||||
if (!field.saved || !previous) {
|
||||
const notes = [addNote(after)]
|
||||
additions.push({
|
||||
kind: 'add',
|
||||
uid: field.uid,
|
||||
key: after.key,
|
||||
label: after.label,
|
||||
notes,
|
||||
flag: flagOf(notes),
|
||||
})
|
||||
} else if (fieldSignature(previous) !== fieldSignature(after)) {
|
||||
const notes = changeNotes(previous, after)
|
||||
changes.push({
|
||||
kind: 'change',
|
||||
uid: field.uid,
|
||||
key: previous.key,
|
||||
label: previous.label,
|
||||
notes,
|
||||
flag: flagOf(notes),
|
||||
})
|
||||
}
|
||||
}
|
||||
const kept = new Set(fields.filter((field) => field.saved).map((field) => field.key))
|
||||
const removals = baseline
|
||||
.filter((field) => !kept.has(field.key))
|
||||
.map(
|
||||
(field): FieldChange => ({
|
||||
kind: 'remove',
|
||||
uid: null,
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
notes: [note(t('Entries stop returning its values.'))],
|
||||
flag: 'removesData',
|
||||
}),
|
||||
)
|
||||
return [...additions, ...changes, ...removals]
|
||||
}
|
||||
|
||||
export function changeTitle(change: FieldChange) {
|
||||
if (change.kind === 'add') {
|
||||
return t('Add {0}', [change.label])
|
||||
}
|
||||
return change.kind === 'change'
|
||||
? t('Change {0}', [change.label])
|
||||
: t('Remove {0}', [change.label])
|
||||
}
|
||||
|
||||
export function changeText(change: FieldChange) {
|
||||
return change.notes.map((entry) => entry.text).join(' ')
|
||||
}
|
||||
|
||||
export function causeText(change: FieldChange) {
|
||||
return change.notes
|
||||
.filter((entry) => entry.flagged)
|
||||
.map((entry) => (entry.hint ? `${entry.text} ${entry.hint}` : entry.text))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function flaggedChanges(changes: readonly FieldChange[]) {
|
||||
return changes.filter((change) => change.flag === 'mayNotFit')
|
||||
}
|
||||
|
||||
export function summaryLine(changes: readonly FieldChange[]) {
|
||||
const count = (kind: ChangeKind) => changes.filter((change) => change.kind === kind).length
|
||||
const added = count('add')
|
||||
const changed = count('change')
|
||||
const removed = count('remove')
|
||||
const parts: string[] = []
|
||||
if (added > 0) {
|
||||
parts.push(added === 1 ? t('Add 1 field') : t('Add {0} fields', [added]))
|
||||
}
|
||||
if (changed > 0) {
|
||||
parts.push(parts.length === 0 ? t('Change {0}', [changed]) : t('change {0}', [changed]))
|
||||
}
|
||||
if (removed > 0) {
|
||||
parts.push(parts.length === 0 ? t('Remove {0}', [removed]) : t('remove {0}', [removed]))
|
||||
}
|
||||
return parts.join(', ')
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { BuiltinField, ContentType } from '@/composables/useContentTypes'
|
||||
import {
|
||||
FIELD_TYPES,
|
||||
createField,
|
||||
type BuilderField,
|
||||
type BuilderForm,
|
||||
type FieldType,
|
||||
} from '@/lib/schemaBuilderModel'
|
||||
import { payloadField, type PayloadField } from '@/lib/schemaBuilderPayload'
|
||||
|
||||
const DATE_LENGTH = 10
|
||||
|
||||
function asText(value: unknown) {
|
||||
return value === null || value === undefined ? '' : String(value)
|
||||
}
|
||||
|
||||
function asDate(value: unknown) {
|
||||
return asText(value).slice(0, DATE_LENGTH)
|
||||
}
|
||||
|
||||
function asType(type: string): FieldType {
|
||||
return (FIELD_TYPES as readonly string[]).includes(type) ? (type as FieldType) : 'text'
|
||||
}
|
||||
|
||||
function asOptions(options: unknown[] | null) {
|
||||
return (options ?? []).map((option) => String(option))
|
||||
}
|
||||
|
||||
export function fieldFromApi(source: BuiltinField, uid: number): BuilderField {
|
||||
const type = asType(source.type)
|
||||
const isDate = type === 'date'
|
||||
const bound = isDate ? asDate : asText
|
||||
return createField(uid, {
|
||||
label: source.label,
|
||||
key: source.key,
|
||||
keyMode: 'manual',
|
||||
type,
|
||||
required: source.required,
|
||||
unique: source.unique,
|
||||
defaultValue: type === 'boolean' ? '' : bound(source.default),
|
||||
defaultOn: type === 'boolean' && source.default === true,
|
||||
min: bound(source.min),
|
||||
max: bound(source.max),
|
||||
options: type === 'select' ? asOptions(source.options) : [],
|
||||
saved: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function formFromType(
|
||||
type: ContentType,
|
||||
newUid: () => number,
|
||||
uids: readonly number[] = [],
|
||||
): BuilderForm {
|
||||
return {
|
||||
name: type.name,
|
||||
key: type.key,
|
||||
keyMode: 'manual',
|
||||
keyLocked: true,
|
||||
fields: type.fields.map((field, index) => fieldFromApi(field, uids[index] ?? newUid())),
|
||||
}
|
||||
}
|
||||
|
||||
export function baselinePayload(type: ContentType): PayloadField[] {
|
||||
return type.fields.map((field) => payloadField(fieldFromApi(field, 0)))
|
||||
}
|
||||
@@ -3,7 +3,14 @@ export const FIELD_TYPES = ['text', 'richtext', 'number', 'boolean', 'date', 'se
|
||||
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 type BuilderStatus =
|
||||
| 'saving'
|
||||
| 'offline'
|
||||
| 'failed'
|
||||
| 'problems'
|
||||
| 'dirty'
|
||||
| 'saved'
|
||||
| 'none'
|
||||
|
||||
export const BUILDER_LIMITS = {
|
||||
nameMax: 100,
|
||||
@@ -32,12 +39,14 @@ export interface BuilderField {
|
||||
min: string
|
||||
max: string
|
||||
options: string[]
|
||||
saved: boolean
|
||||
}
|
||||
|
||||
export interface BuilderForm {
|
||||
name: string
|
||||
key: string
|
||||
keyMode: KeyMode
|
||||
keyLocked: boolean
|
||||
fields: BuilderField[]
|
||||
}
|
||||
|
||||
@@ -62,6 +71,7 @@ export function createField(uid: number, overrides: Partial<BuilderField> = {}):
|
||||
min: '',
|
||||
max: '',
|
||||
options: [],
|
||||
saved: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -71,7 +81,13 @@ export function starterField(uid: number): BuilderField {
|
||||
}
|
||||
|
||||
export function blankForm(uid: number): BuilderForm {
|
||||
return { name: '', key: '', keyMode: 'auto', fields: [starterField(uid)] }
|
||||
return {
|
||||
name: '',
|
||||
key: '',
|
||||
keyMode: 'auto',
|
||||
keyLocked: false,
|
||||
fields: [starterField(uid)],
|
||||
}
|
||||
}
|
||||
|
||||
export function changeFieldType(field: BuilderField, type: FieldType): BuilderField {
|
||||
|
||||
@@ -43,6 +43,12 @@ function defaultFor(field: BuilderField) {
|
||||
return field.type === 'number' ? Number(given) : given
|
||||
}
|
||||
|
||||
export interface UpdatePayload {
|
||||
name: string
|
||||
icon: string | null
|
||||
fields: PayloadField[]
|
||||
}
|
||||
|
||||
export function payloadField(field: BuilderField): PayloadField {
|
||||
return {
|
||||
key: field.key,
|
||||
@@ -64,3 +70,11 @@ export function buildPayload(form: BuilderForm): ContentTypePayload {
|
||||
fields: form.fields.map(payloadField),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildUpdatePayload(form: BuilderForm, icon: string | null): UpdatePayload {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
icon,
|
||||
fields: form.fields.map(payloadField),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BuilderField } from '@/lib/schemaBuilderModel'
|
||||
|
||||
export interface RemovedField {
|
||||
field: BuilderField
|
||||
}
|
||||
|
||||
export function restoreIndex(
|
||||
current: readonly BuilderField[],
|
||||
baselineKeys: readonly string[],
|
||||
removedKey: string,
|
||||
) {
|
||||
const target = baselineKeys.indexOf(removedKey)
|
||||
let index = 0
|
||||
current.forEach((field, position) => {
|
||||
const place = field.saved ? baselineKeys.indexOf(field.key) : -1
|
||||
if (place !== -1 && place < target) {
|
||||
index = position + 1
|
||||
}
|
||||
})
|
||||
return index
|
||||
}
|
||||
|
||||
export function reservedKeys(removed: readonly RemovedField[]) {
|
||||
return removed.map((entry) => entry.field.key)
|
||||
}
|
||||
@@ -51,10 +51,12 @@ export function validateType(form: BuilderForm): TypeErrors {
|
||||
} 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.keyLocked) {
|
||||
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.')
|
||||
@@ -164,6 +166,9 @@ function choiceErrors(field: BuilderField, errors: FieldErrors) {
|
||||
}
|
||||
|
||||
function keyError(field: BuilderField, earlierKeys: readonly string[]) {
|
||||
if (field.saved) {
|
||||
return undefined
|
||||
}
|
||||
if (field.keyMode === 'auto' && field.label.trim() === '') {
|
||||
return undefined
|
||||
}
|
||||
@@ -201,11 +206,12 @@ export function hasFieldErrors(errors: FieldErrors) {
|
||||
return Object.keys(errors).length > 0
|
||||
}
|
||||
|
||||
export function validateForm(form: BuilderForm): FormErrors {
|
||||
export function validateForm(form: BuilderForm, reserved: readonly string[] = []): FormErrors {
|
||||
const fields = new Map<number, FieldErrors>()
|
||||
const savedKeys = form.fields.filter((field) => field.saved).map((field) => field.key)
|
||||
const earlierKeys: string[] = []
|
||||
for (const field of form.fields) {
|
||||
const errors = validateField(field, earlierKeys)
|
||||
const errors = validateField(field, [...earlierKeys, ...savedKeys, ...reserved])
|
||||
if (hasFieldErrors(errors)) {
|
||||
fields.set(field.uid, errors)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { formatNumber, t } from '@/i18n'
|
||||
|
||||
export function entriesNotice(total: number) {
|
||||
if (total <= 0) {
|
||||
return ''
|
||||
}
|
||||
return total === 1
|
||||
? t('1 entry uses this content type. Changes are checked against it when you save.')
|
||||
: t('{0} entries use this content type. Changes are checked against them when you save.', [
|
||||
formatNumber(total),
|
||||
])
|
||||
}
|
||||
|
||||
export function entriesLine(name: string, total: number) {
|
||||
return total === 1
|
||||
? t('{0} has 1 entry. Each one is checked against the new fields when you apply.', [name])
|
||||
: t('{0} has {1} entries. Each one is checked against the new fields when you apply.', [
|
||||
name,
|
||||
formatNumber(total),
|
||||
])
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export function customRow(type: ContentType): SchemaRow {
|
||||
fieldCount: type.fields.length,
|
||||
updated: formatAbsoluteDateFromIso(type.updated_at),
|
||||
builtin: false,
|
||||
to: `/schema/${type.key}`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user