feat: edit an existing custom type

This commit is contained in:
Florian Sylvain committed 2026-10-20 04:42:50 +02:00
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)
})
})
+42
View File
@@ -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: [] }),
]
+263
View File
@@ -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(', ')
}
+65
View File
@@ -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)))
}
+18 -2
View File
@@ -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 {
+14
View File
@@ -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),
}
}
+25
View File
@@ -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)
}
+12 -6
View File
@@ -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)
}
+21
View File
@@ -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),
])
}
+1
View File
@@ -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}`,
}
}