feat: entry editor at /content/:key/new and /content/:key/:id

This commit is contained in:
Florian Sylvain committed 2026-10-21 00:12:34 +02:00
1 parent 91d916fb74
commit fba20f83c5
94 files changed
+7116 -423

No files matched your search

+3
View File
@@ -65,6 +65,9 @@ describe('richTextFirstLine', () => {
['![alt text](https://example.test/a.png)', 'alt text'],
['`code` and ~~gone~~', 'code and gone'],
['snake_case stays', 'snake_case stays'],
['_lime_ plaster', 'lime plaster'],
['Two days of _lime putty_.', 'Two days of lime putty.'],
['(_lime_) and my_var_name', '(lime) and my_var_name'],
])('turns "%s" into "%s"', (markdown, expected) => {
expect(richTextFirstLine(markdown)).toBe(expected)
})
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { resolveEditorPhase, resolveEntryTarget } from '@/lib/entryEditorState'
describe('resolveEntryTarget', () => {
it.each([
['new', 'new'],
['42', 42],
['abc', 'invalid'],
['0', 0],
['4294967296', 'invalid'],
['4294967295', 4294967295],
['-1', 'invalid'],
['4.2', 'invalid'],
['', 'invalid'],
])('%s resolves to %s', (raw, target) => {
expect(resolveEntryTarget(raw)).toBe(target)
})
})
describe('resolveEditorPhase', () => {
it.each([
['ready', 'ready', 'ready'],
['ready', 'idle', 'ready'],
['loading', 'ready', 'loading'],
['ready', 'loading', 'loading'],
['missing', 'ready', 'typeNotFound'],
['ready', 'typeMissing', 'typeNotFound'],
['ready', 'missing', 'entryNotFound'],
['forbidden', 'ready', 'forbidden'],
['ready', 'forbidden', 'forbidden'],
['failed', 'loading', 'typeError'],
['ready', 'failed', 'entryError'],
] as const)('type %s and entry %s is %s', (type, entry, phase) => {
expect(resolveEditorPhase(type, entry)).toBe(phase)
})
})
+218
View File
@@ -0,0 +1,218 @@
import { describe, expect, it } from 'vitest'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import {
buildData,
carriesDefault,
entryTitle,
firstIssueKey,
initialValues,
validateEntry,
valuesDiffer,
} from '@/lib/entryForm'
import { groupFields } from '@/lib/entryLayout'
import { apiField, eventsFields } from './schemaFixtures'
const fields = eventsFields as unknown as BuiltinField[]
function entry(data: Record<string, unknown>, id = 42): ContentEntry {
return {
id,
content_type: 'events',
data,
created_by: 1,
updated_by: 1,
created_at: '2026-10-12T09:14:00',
updated_at: '2026-10-12T09:14:00',
}
}
describe('initialValues', () => {
it('pre-fills defaults on create', () => {
expect(initialValues(fields, null)).toEqual({
title: '',
starts_on: '',
ends_on: '',
online: false,
capacity: null,
category: '',
description: '',
})
})
it('uses the stored values on edit and no default', () => {
const values = initialValues(fields, entry({ title: 'Lime', online: null, capacity: 18 }))
expect(values).toMatchObject({ title: 'Lime', online: null, capacity: 18, ends_on: '' })
})
})
describe('buildData', () => {
it('sends one key per field with null for empty optional fields', () => {
const values = {
...initialValues(fields, null),
title: 'Open day',
starts_on: '2026-11-21',
}
expect(buildData(fields, values)).toEqual({
title: 'Open day',
starts_on: '2026-11-21',
ends_on: null,
online: false,
capacity: null,
category: null,
description: null,
})
})
it('never sends a key the type does not have', () => {
const stored = entry({ title: 'Lime', removed: 'x' })
expect(Object.keys(buildData(fields, initialValues(fields, stored)))).toEqual(
fields.map((field) => field.key),
)
})
it.each([
['optional, no default', {}, undefined, null],
['optional, loaded as null', { default: false }, null, null],
['required, no default', { required: true }, undefined, false],
['optional, default true', { default: true }, 'default', true],
])('a boolean that was never set: %s', (_name, rest, stored, sent) => {
const field = apiField('Flag', 'flag', 'boolean', rest) as unknown as BuiltinField
const loaded =
stored === undefined ? null : stored === 'default' ? null : entry({ flag: null })
const values = initialValues([field], stored === 'default' ? null : loaded)
expect(buildData([field], values).flag).toBe(sent)
})
})
describe('validateEntry', () => {
const base = initialValues(fields, null)
it('reports every empty required field at once, in schema order', () => {
const issues = validateEntry(fields, base)
expect(Object.keys(issues)).toEqual(['title', 'starts_on'])
expect(issues.title!.message).toBe('Fill in Title.')
expect(issues.starts_on!.message).toBe('Pick a date for Starts on.')
expect(firstIssueKey(fields, issues)).toBe('title')
})
it('reports one rule per field, with the message of the rule', () => {
const issues = validateEntry(fields, {
...base,
title: 'x'.repeat(91),
starts_on: '2026-11-21',
capacity: 6000,
category: 'Seminar',
})
expect(
Object.fromEntries(Object.entries(issues).map(([key, issue]) => [key, issue.message])),
).toEqual({
title: 'Title can be up to 80 characters.',
capacity: 'Capacity must be at most 5000.',
category: 'Choose one of: Talk, Workshop or Meetup.',
})
})
it.each([
['Title', 'text', { min: 3 }, 'ab', 'Title needs at least 3 characters.'],
['Capacity', 'number', { min: 1 }, 0, 'Capacity must be at least 1.'],
[
'Starts on',
'date',
{ min: '2026-01-01' },
'2025-12-31',
"Starts on can't be before 1 Jan 2026.",
],
[
'Starts on',
'date',
{ max: '2027-12-31' },
'2028-01-01',
"Starts on can't be after 31 Dec 2027.",
],
['Starts on', 'date', {}, '2026-02-30', 'Starts on must be a date, like 2026-11-21.'],
])('%s (%s %j) holding %j reads "%s"', (label, type, rest, value, message) => {
const field = apiField(label, 'f', type, rest) as unknown as BuiltinField
expect(validateEntry([field], { f: value }).f!.message).toBe(message)
})
it.each([
['text', 'Title', 'Fill in Title.'],
['richtext', 'Notes', 'Fill in Notes.'],
['number', 'Capacity', 'Fill in Capacity.'],
['date', 'Starts on', 'Pick a date for Starts on.'],
['select', 'Category', 'Choose an option for Category.'],
])('required %s %s reads "%s"', (type, label, message) => {
const field = apiField(label, 'f', type, { required: true }) as unknown as BuiltinField
expect(validateEntry([field], { f: type === 'number' ? null : '' }).f!.message).toBe(
message,
)
})
})
describe('entryTitle', () => {
it.each([
['Lime plaster workshop', 'text', 'Lime plaster workshop'],
[null, 'text', 'Entry 42'],
['First line\n\nSecond', 'richtext', 'First line'],
[18, 'number', '18'],
])('%j of a %s field reads %s', (value, type, title) => {
const first = apiField('First', 'first', type) as unknown as BuiltinField
expect(entryTitle([first], entry({ first: value }))).toBe(title)
})
it('reads New entry without an entry', () => {
expect(entryTitle(fields, null)).toBe('New entry')
})
})
describe('defaults and changes', () => {
it('tags a value equal to the default and drops the tag on change', () => {
const online = fields[3]!
expect(carriesDefault(online, false)).toBe(true)
expect(carriesDefault(online, true)).toBe(false)
expect(carriesDefault(fields[0]!, '')).toBe(false)
})
it('differs only when a value changed, and not when it is restored', () => {
const saved = initialValues(fields, entry({ capacity: 18 }))
expect(valuesDiffer(fields, { ...saved, capacity: 20 }, saved)).toBe(true)
expect(valuesDiffer(fields, { ...saved, capacity: 18 }, saved)).toBe(false)
})
})
describe('groupFields', () => {
it('pairs dates, numbers and booleans, never text, rich text or select', () => {
expect(groupFields(fields).map((row) => row.map((field) => field.key))).toEqual([
['title'],
['starts_on', 'ends_on'],
['online', 'capacity'],
['category'],
['description'],
])
})
it('keeps a pairable field alone when the next one cannot pair', () => {
const loose = [
{ ...fields.find((field) => field.key === 'capacity')! },
{ ...fields.find((field) => field.key === 'title')! },
{ ...fields.find((field) => field.key === 'online')! },
]
expect(groupFields(loose).map((row) => row.map((field) => field.key))).toEqual([
['capacity'],
['title'],
['online'],
])
})
})
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import type { BuiltinField } from '@/lib/contentTypes'
import { ENTRY_EDITORS } from '@/lib/entryEditors'
import { asNumber, freeSentinel, isIsoDay } from '@/lib/entryValue'
import { valuesDiffer } from '@/lib/entryForm'
function field(type: string, rest: Partial<BuiltinField> = {}): BuiltinField {
return {
key: 'f',
label: 'Field',
type,
required: false,
unique: false,
default: null,
min: null,
max: null,
options: null,
...rest,
}
}
describe('length limits', () => {
const text = field('text', { min: 3, max: 5 })
it.each([
['ab', 'min'],
['abc', null],
['abcde', null],
['abcdef', 'max'],
])('%s is judged %s', (value, rule) => {
expect(ENTRY_EDITORS.text.validate(text, value)).toBe(rule)
})
it('flags the counter only once over the maximum', () => {
expect(ENTRY_EDITORS.text.counter(text, 'abcde')).toEqual({ text: '5 / 5', over: false })
expect(ENTRY_EDITORS.text.counter(text, 'abcdef')).toEqual({ text: '6 / 5', over: true })
})
})
describe('number limits', () => {
const number = field('number', { min: 2, max: 4 })
it.each([
[1, 'min'],
[2, null],
[4, null],
[5, 'max'],
])('%s is judged %s', (value, rule) => {
expect(ENTRY_EDITORS.number.validate(number, value)).toBe(rule)
})
it('sends an empty string as null', () => {
expect(ENTRY_EDITORS.number.payload(number, '')).toBeNull()
expect(ENTRY_EDITORS.number.payload(number, 0)).toBe(0)
})
})
describe('date limits', () => {
const date = field('date', { min: '2026-11-10', max: '2026-11-20' })
it.each([
['2026-11-09', 'min'],
['2026-11-10', null],
['2026-11-20', null],
['2026-11-21', 'max'],
['2026-02-30', 'type'],
])('%s is judged %s', (value, rule) => {
expect(ENTRY_EDITORS.date.validate(date, value)).toBe(rule)
})
})
describe('value helpers', () => {
it('rejects a day that does not exist', () => {
expect(isIsoDay('2026-02-28')).toBe(true)
expect(isIsoDay('2026-02-30')).toBe(false)
expect(isIsoDay('2026-13-01')).toBe(false)
})
it('reads blank strings as no number', () => {
expect(asNumber(' ')).toBeNull()
expect(asNumber(' 7 ')).toBe(7)
expect(asNumber('0')).toBe(0)
})
})
describe('freeSentinel', () => {
it('returns the base when free and extends it until it is not taken', () => {
expect(freeSentinel(['a'])).toBe('__none__')
expect(freeSentinel(['__none__'])).toBe('__none___')
expect(freeSentinel(['__none__', '__none___'])).toBe('__none____')
})
})
describe('valuesDiffer', () => {
it('compares each field by identity', () => {
const fields = [field('number')]
expect(valuesDiffer(fields, { f: 0 }, { f: 0 })).toBe(false)
expect(valuesDiffer(fields, { f: 0 }, { f: null })).toBe(true)
expect(valuesDiffer(fields, { f: '' }, { f: null })).toBe(true)
})
})
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import { ApiError } from '@/composables/useApi'
import type { BuiltinField } from '@/lib/contentTypes'
import { classifyRefusal, isGoneError, parseEntryRefusal } from '@/lib/entryServerError'
import { eventsFields } from './schemaFixtures'
const fields = eventsFields as unknown as BuiltinField[]
function refusal(status: number, body: string) {
return new ApiError(status, undefined, body)
}
describe('parseEntryRefusal', () => {
it.each([
[422, 'invalid_entry: title: required', { key: 'title', rule: 'required', status: 422 }],
[409, 'invalid_entry: title: unique', { key: 'title', rule: 'unique', status: 409 }],
[
422,
'invalid_entry: room: unknown_field',
{ key: 'room', rule: 'unknown_field', status: 422 },
],
])('%i %s', (status, body, parsed) => {
expect(parseEntryRefusal(refusal(status, body))).toEqual(parsed)
})
it.each([
[422, 'invalid_entry'],
[400, 'invalid_entry: title: required'],
[500, 'boom'],
[422, ''],
])('ignores %i "%s"', (status, body) => {
expect(parseEntryRefusal(refusal(status, body))).toBeNull()
})
it('ignores anything that is not an api error', () => {
expect(parseEntryRefusal(new Error('x'))).toBeNull()
})
})
describe('classifyRefusal', () => {
it.each([
['title', 'required', 'field'],
['capacity', 'max', 'field'],
['category', 'options', 'field'],
['room', 'required', 'schema'],
['room', 'unique', 'schema'],
['description', 'unknown_field', 'schema'],
['colour', 'unknown_field', 'schema'],
])('%s %s is %s', (key, rule, kind) => {
const outcome = classifyRefusal(fields, { key, rule, status: 422 })
expect(outcome.kind).toBe(kind)
})
it('carries the field of a field outcome and the key of a schema outcome', () => {
const title = fields.find((field) => field.key === 'title')!
expect(classifyRefusal(fields, { key: 'title', rule: 'min', status: 422 })).toEqual({
kind: 'field',
field: title,
rule: 'min',
status: 422,
})
expect(classifyRefusal(fields, { key: 'room', rule: 'required', status: 422 })).toEqual({
kind: 'schema',
key: 'room',
})
})
it('has no outcome without a refusal', () => {
expect(classifyRefusal(fields, null)).toEqual({ kind: 'unknown' })
})
})
describe('isGoneError', () => {
it.each([
[403, true],
[404, true],
[401, false],
[409, false],
[500, false],
])('reads status %i as %s', (status, gone) => {
expect(isGoneError(new ApiError(status))).toBe(gone)
})
it('ignores errors that are not API errors', () => {
expect(isGoneError(new Error('boom'))).toBe(false)
expect(isGoneError(undefined)).toBe(false)
})
})
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest'
import { FIELD_TYPES, fieldTypeSpec, type FieldType } from '@/lib/fieldTypes'
import { apiField } from './schemaFixtures'
const CONTROLS: Record<FieldType, string> = {
text: 'input',
richtext: 'markdown',
number: 'number',
boolean: 'switch',
date: 'date',
select: 'select',
}
function editor(type: FieldType) {
return fieldTypeSpec(type).editor
}
describe('field type registry', () => {
it.each(FIELD_TYPES)('%s has an icon, a label, a description and a control', (type) => {
const spec = fieldTypeSpec(type)
expect(spec.icon).toMatch(/^i-lucide-/)
expect(spec.label()).not.toBe('')
expect(spec.description()).not.toBe('')
expect(spec.editor.control).toBe(CONTROLS[type])
})
it('falls back to text for an unknown type', () => {
expect(fieldTypeSpec('mystery')).toBe(fieldTypeSpec('text'))
})
it.each([
['text', ''],
['richtext', ''],
['number', null],
['boolean', null],
['date', ''],
['select', ''],
] as const)(
'%s starts empty on create without a default and from null on edit',
(type, empty) => {
const field = apiField('Field', 'field', type)
expect(editor(type).initial(field, undefined, true)).toBe(empty)
expect(editor(type).initial(field, null, false)).toBe(empty)
},
)
it.each([
['text', 'Untitled', 'Untitled'],
['richtext', '# Hello', '# Hello'],
['number', 100, 100],
['boolean', true, true],
['boolean', false, false],
['date', '2026-11-21', '2026-11-21'],
['select', 'Talk', 'Talk'],
] as const)('%s pre-fills its default %j on create only', (type, given, shown) => {
const field = apiField('Field', 'field', type, { default: given })
expect(editor(type).initial(field, undefined, true)).toBe(shown)
expect(editor(type).initial(field, null, false)).not.toBe(shown)
})
it.each([
['text', { unique: true, label: 'Title' }, 'Each entry needs a different title.'],
['text', {}, ''],
['number', { min: 1, max: 5000 }, 'Between 1 and 5000.'],
['number', { min: 1 }, 'At least 1.'],
['number', { max: 5000 }, 'At most 5000.'],
['date', { min: '2026-01-01', max: '2027-12-31' }, 'From 1 Jan 2026 until 31 Dec 2027.'],
['richtext', {}, 'Markdown. Switch to Preview to check the result.'],
['boolean', {}, ''],
['select', {}, ''],
] as const)('%s help line for %j reads "%s"', (type, rest, help) => {
const field = apiField(rest.label ?? 'Field', 'field', type, rest)
expect(editor(type).help(field)).toBe(help)
})
it.each(['text', 'richtext'] as const)('%s counts characters when it has a max', (type) => {
const field = apiField('Field', 'field', type, { max: 80 })
expect(editor(type).counter(field, 'a'.repeat(32))).toEqual({
text: '32 / 80',
over: false,
})
expect(editor(type).counter(field, 'a'.repeat(91))).toEqual({
text: '91 / 80',
over: true,
})
expect(editor(type).counter(apiField('F', 'f', type), 'abc')).toBeNull()
expect(editor(type).counter(field, '😀😀')?.text).toBe('2 / 80')
})
it.each([
['text', { required: true }, '', 'required'],
['text', { min: 3 }, 'ab', 'min'],
['text', { max: 3 }, 'abcd', 'max'],
['text', { min: 3 }, '', null],
['richtext', { required: true }, null, 'required'],
['number', { required: true }, null, 'required'],
['number', { min: 1 }, 0, 'min'],
['number', { max: 5000 }, 6000, 'max'],
['number', {}, 0, null],
['boolean', { required: true }, null, null],
['boolean', {}, 'yes', 'type'],
['date', { required: true }, '', 'required'],
['date', { min: '2026-01-01' }, '2025-12-31', 'min'],
['date', { max: '2027-12-31' }, '2028-01-01', 'max'],
['date', {}, '2026-02-30', 'type'],
['select', { required: true }, '', 'required'],
['select', { options: ['Talk'] }, 'Seminar', 'options'],
['select', { options: ['Talk'] }, 'Talk', null],
] as const)('%s %j holding %j gives %s', (type, rest, value, rule) => {
const field = apiField('Field', 'field', type, rest)
expect(editor(type).validate(field, value)).toBe(rule)
})
it.each([
['text', {}, '', null],
['text', {}, ' kept ', ' kept '],
['richtext', {}, '', null],
['number', {}, null, null],
['number', {}, 0, 0],
['boolean', {}, null, null],
['boolean', { required: true }, null, false],
['boolean', {}, true, true],
['date', {}, '', null],
['date', {}, '2026-11-21', '2026-11-21'],
['select', {}, '', null],
['select', {}, 'Talk', 'Talk'],
] as const)('%s %j holding %j sends %j', (type, rest, value, sent) => {
const field = apiField('Field', 'field', type, rest)
expect(editor(type).payload(field, value)).toBe(sent)
})
it.each([
['text', true],
['richtext', false],
['number', true],
['boolean', false],
['date', true],
['select', true],
] as const)('%s unique support is %s', (type, unique) => {
expect(fieldTypeSpec(type).supportsUnique).toBe(unique)
})
})
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import { renderMarkdown } from '@/lib/markdown'
import { applyMarkdownAction } from '@/lib/markdownEdit'
describe('renderMarkdown', () => {
it('renders bold text and a bulleted list', () => {
const html = renderMarkdown(
'Two days with **lime putty**.\n\n- Bring work clothes\n- Lunch',
)
expect(html).toContain('<strong>lime putty</strong>')
expect(html.match(/<li>/g)).toHaveLength(2)
})
it('shows raw HTML as text', () => {
const html = renderMarkdown('<script>alert(1)</script>')
expect(html).not.toContain('<script>')
expect(html).toContain('&lt;script&gt;')
})
it.each(['https://example.com', 'http://example.com', 'mailto:a@b.co'])(
'keeps the link %s active',
(url) => {
expect(renderMarkdown(`[x](${url})`)).toContain(`href="${url}"`)
},
)
it.each(['javascript:alert(1)', 'data:text/html,x', 'vbscript:x', '/relative', 'url'])(
'leaves the link %s inert',
(url) => {
expect(renderMarkdown(`[x](${url})`)).not.toContain('<a ')
},
)
it('opens links in a new tab without leaking the opener', () => {
const html = renderMarkdown('[x](https://example.com)')
expect(html).toContain('target="_blank"')
expect(html).toContain('rel="noopener noreferrer"')
})
it('does not load images', () => {
expect(renderMarkdown('![x](https://example.com/a.png)')).not.toContain('<img')
})
})
describe('applyMarkdownAction', () => {
it.each([
['bold', '**lime**'],
['italic', '_lime_'],
['heading', '## lime'],
['link', '[lime](url)'],
['list', '- lime'],
] as const)('%s turns the selection into %s', (action, result) => {
expect(applyMarkdownAction(action, 'lime', 0, 4).value).toBe(result)
})
it('selects the url placeholder of a link', () => {
const edit = applyMarkdownAction('link', 'see lime now', 4, 8)
expect(edit.value.slice(edit.start, edit.end)).toBe('url')
})
it('prefixes every selected line of a list', () => {
expect(applyMarkdownAction('list', 'a\nb', 0, 3).value).toBe('- a\n- b')
})
it('puts the heading mark at the start of the line', () => {
expect(applyMarkdownAction('heading', 'one\ntwo lime', 8, 12).value).toBe(
'one\n## two lime',
)
})
it('moves the selection past the list marker of the first line', () => {
const edit = applyMarkdownAction('list', 'a\nb', 0, 3)
expect([edit.start, edit.end]).toEqual([2, 7])
})
it('prefixes only the current line when the cursor sits right after a line break', () => {
expect(applyMarkdownAction('list', 'one\ntwo', 4, 4).value).toBe('one\n- two')
expect(applyMarkdownAction('heading', 'one\ntwo', 4, 4).value).toBe('one\n## two')
})
it('prefixes the line the cursor ends, not the next one', () => {
expect(applyMarkdownAction('heading', 'one\ntwo', 3, 3).value).toBe('## one\ntwo')
})
it('prefixes the first line when it is empty and the caret sits at the start', () => {
expect(applyMarkdownAction('heading', '\nfoo', 0, 0).value).toBe('## \nfoo')
expect(applyMarkdownAction('list', '\nfoo', 0, 0).value).toBe('- \nfoo')
})
})
+4 -3
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { BuiltinField } from '@/composables/useContentTypes'
import { fieldIcon, fieldRules, fieldTypeLabel } from '@/lib/schemaFields'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import { fieldRules } from '@/lib/schemaFields'
function field(overrides: Partial<BuiltinField>): BuiltinField {
return {
@@ -26,8 +27,8 @@ describe('field types', () => {
['date', 'Date', 'i-lucide-calendar'],
['select', 'Choice', 'i-lucide-list'],
])('%s reads %s with %s', (type, label, icon) => {
expect(fieldTypeLabel(type)).toBe(label)
expect(fieldIcon(type)).toBe(icon)
expect(fieldTypeSpec(type).label()).toBe(label)
expect(fieldTypeSpec(type).icon).toBe(icon)
})
})