feat: entry editor at /content/:key/new and /content/:key/:id
This commit is contained in:
1 parent
91d916fb74
commit
fba20f83c5
94 files changed
+7116
-423
No files matched your search
@@ -65,6 +65,9 @@ describe('richTextFirstLine', () => {
|
||||
['', '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)
|
||||
})
|
||||
})
|
||||
@@ -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'],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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('<script>')
|
||||
})
|
||||
|
||||
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('')).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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user