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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
retryAfter?: number
|
||||
body?: string
|
||||
|
||||
constructor(status: number, retryAfter?: number, body?: string) {
|
||||
super(`Request failed with status ${status}`)
|
||||
this.status = status
|
||||
this.retryAfter = retryAfter
|
||||
this.body = body
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function typeEndpoint(key: string) {
|
||||
return `/v1/content-type/${encodeURIComponent(key)}`
|
||||
}
|
||||
|
||||
export function entryEndpoint(key: string, id?: number) {
|
||||
const base = `${typeEndpoint(key)}/entry`
|
||||
return id === undefined ? base : `${base}/${id}`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ContentType } from '@/composables/useContentTypes'
|
||||
import type { ContentType } from '@/lib/contentTypes'
|
||||
|
||||
export const COLLAPSED_LIMIT = 5
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ export interface BuiltinField {
|
||||
required: boolean
|
||||
unique: boolean
|
||||
default: unknown
|
||||
min: number | null
|
||||
max: number | null
|
||||
min: number | string | null
|
||||
max: number | string | null
|
||||
options: unknown[] | null
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface ContentType {
|
||||
|
||||
export interface ContentEntry {
|
||||
id: number
|
||||
content_type_id: number
|
||||
content_type: string
|
||||
data: Record<string, unknown>
|
||||
created_by: number
|
||||
updated_by: number
|
||||
@@ -35,7 +35,6 @@ export interface EntryPage {
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
sort: string
|
||||
}
|
||||
|
||||
export interface EntryPageQuery {
|
||||
|
||||
+17
-21
@@ -1,8 +1,12 @@
|
||||
import { formatNumber, t } from '@/i18n'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import { formatAbsoluteDate, formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||
import { fieldTypeSpec, type FieldCellKind } from '@/lib/fieldTypes'
|
||||
import { formatDayValue } from '@/lib/entryValue'
|
||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||
|
||||
export type CellKind = 'empty' | 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'choice'
|
||||
export { formatDayValue }
|
||||
|
||||
export type CellKind = 'empty' | FieldCellKind
|
||||
|
||||
export interface CellView {
|
||||
kind: CellKind
|
||||
@@ -13,10 +17,10 @@ export interface CellView {
|
||||
export const EMPTY_TEXT = '–'
|
||||
|
||||
const EMPTY: CellView = { kind: 'empty', text: '' }
|
||||
const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})/
|
||||
const LINE_PREFIX = /^\s{0,3}(?:#{1,6}(?:\s+|$)|>\s?|[-*+]\s+|\d+[.)]\s+)+/
|
||||
const IMAGE_OR_LINK = /!?\[([^\]]*)\]\([^)]*\)/g
|
||||
const EMPHASIS = /(\*\*|__|\*|`|~~)/g
|
||||
const UNDERSCORE_EMPHASIS = /(^|[\s(])_(?=\S)([^_]*?\S)_(?=$|[\s).,;:!?])/g
|
||||
|
||||
export function richTextFirstLine(markdown: string) {
|
||||
for (const line of markdown.split(/\r?\n/)) {
|
||||
@@ -24,6 +28,7 @@ export function richTextFirstLine(markdown: string) {
|
||||
.replace(LINE_PREFIX, '')
|
||||
.replace(IMAGE_OR_LINK, '$1')
|
||||
.replace(EMPHASIS, '')
|
||||
.replace(UNDERSCORE_EMPHASIS, '$1$2')
|
||||
.trim()
|
||||
if (plain) {
|
||||
return plain
|
||||
@@ -32,35 +37,26 @@ export function richTextFirstLine(markdown: string) {
|
||||
return ''
|
||||
}
|
||||
|
||||
export function formatDayValue(value: string) {
|
||||
const match = ISO_DAY.exec(value)
|
||||
if (!match) {
|
||||
return value
|
||||
}
|
||||
return formatAbsoluteDate(new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])))
|
||||
}
|
||||
|
||||
export function describeCell(field: BuiltinField, value: unknown): CellView {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return EMPTY
|
||||
}
|
||||
switch (field.type) {
|
||||
const kind = fieldTypeSpec(field.type).cell
|
||||
switch (kind) {
|
||||
case 'boolean':
|
||||
return { kind: 'boolean', text: '', yes: value === true }
|
||||
return { kind, text: '', yes: value === true }
|
||||
case 'number':
|
||||
return typeof value === 'number'
|
||||
? { kind: 'number', text: formatNumber(value) }
|
||||
: { kind: 'number', text: String(value) }
|
||||
return { kind, text: typeof value === 'number' ? formatNumber(value) : String(value) }
|
||||
case 'date':
|
||||
return { kind: 'date', text: formatDayValue(String(value)) }
|
||||
case 'select':
|
||||
return { kind: 'choice', text: String(value) }
|
||||
return { kind, text: formatDayValue(String(value)) }
|
||||
case 'choice':
|
||||
return { kind, text: String(value) }
|
||||
case 'richtext': {
|
||||
const line = richTextFirstLine(String(value))
|
||||
return line ? { kind: 'richtext', text: line } : EMPTY
|
||||
return line ? { kind, text: line } : EMPTY
|
||||
}
|
||||
default:
|
||||
return { kind: 'text', text: String(value) }
|
||||
return { kind, text: String(value) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ContentTypeDetailStatus } from '@/lib/contentTypes'
|
||||
|
||||
export type EntryLoadStatus =
|
||||
| 'idle'
|
||||
| 'loading'
|
||||
| 'ready'
|
||||
| 'failed'
|
||||
| 'forbidden'
|
||||
| 'missing'
|
||||
| 'typeMissing'
|
||||
|
||||
export type EntryEditorPhase =
|
||||
| 'loading'
|
||||
| 'ready'
|
||||
| 'typeNotFound'
|
||||
| 'entryNotFound'
|
||||
| 'forbidden'
|
||||
| 'typeError'
|
||||
| 'entryError'
|
||||
|
||||
const CONTENT_TYPE_MISSING_BODY = 'content_type_not_found'
|
||||
|
||||
export function missingKind(body: string | undefined): 'missing' | 'typeMissing' {
|
||||
return body === CONTENT_TYPE_MISSING_BODY ? 'typeMissing' : 'missing'
|
||||
}
|
||||
|
||||
export function resolveEditorPhase(
|
||||
type: ContentTypeDetailStatus,
|
||||
entry: EntryLoadStatus,
|
||||
): EntryEditorPhase {
|
||||
if (type === 'missing' || entry === 'typeMissing') {
|
||||
return 'typeNotFound'
|
||||
}
|
||||
if (entry === 'missing') {
|
||||
return 'entryNotFound'
|
||||
}
|
||||
if (type === 'forbidden' || entry === 'forbidden') {
|
||||
return 'forbidden'
|
||||
}
|
||||
if (type === 'failed') {
|
||||
return 'typeError'
|
||||
}
|
||||
if (entry === 'failed') {
|
||||
return 'entryError'
|
||||
}
|
||||
if (type === 'loading' || entry === 'loading') {
|
||||
return 'loading'
|
||||
}
|
||||
return 'ready'
|
||||
}
|
||||
|
||||
export type EntryTarget = number | 'new' | 'invalid'
|
||||
|
||||
const DIGITS = /^\d+$/
|
||||
const UINT32_MAX = 4294967295
|
||||
|
||||
export function resolveEntryTarget(raw: string): EntryTarget {
|
||||
if (raw === 'new') {
|
||||
return 'new'
|
||||
}
|
||||
if (!DIGITS.test(raw)) {
|
||||
return 'invalid'
|
||||
}
|
||||
const id = Number(raw)
|
||||
return id >= 0 && id <= UINT32_MAX ? id : 'invalid'
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import { rangeHelp, uniqueHelp, type EntryRule } from '@/lib/entryMessages'
|
||||
import { asBoolean, asNumber, asText, charCount, isBlank, isIsoDay } from '@/lib/entryValue'
|
||||
import type { FieldType } from '@/lib/fieldTypes'
|
||||
|
||||
export type FieldControl = 'input' | 'markdown' | 'number' | 'switch' | 'date' | 'select'
|
||||
|
||||
export interface FieldCounter {
|
||||
text: string
|
||||
over: boolean
|
||||
}
|
||||
|
||||
export interface EntryEditorSpec {
|
||||
control: FieldControl
|
||||
initial: (field: BuiltinField, stored: unknown, creating: boolean) => unknown
|
||||
validate: (field: BuiltinField, value: unknown) => EntryRule | null
|
||||
payload: (field: BuiltinField, value: unknown) => unknown
|
||||
help: (field: BuiltinField) => string
|
||||
counter: (field: BuiltinField, value: unknown) => FieldCounter | null
|
||||
}
|
||||
|
||||
type Limit = number | string | null
|
||||
|
||||
function textInitial(field: BuiltinField, stored: unknown, creating: boolean) {
|
||||
return asText(creating ? field.default : stored)
|
||||
}
|
||||
|
||||
function textPayload(_field: BuiltinField, value: unknown) {
|
||||
return isBlank(value) ? null : String(value)
|
||||
}
|
||||
|
||||
function lengthViolation(field: BuiltinField, value: unknown): EntryRule | null {
|
||||
if (isBlank(value)) {
|
||||
return field.required ? 'required' : null
|
||||
}
|
||||
const length = charCount(String(value))
|
||||
if (typeof field.min === 'number' && length < field.min) {
|
||||
return 'min'
|
||||
}
|
||||
return typeof field.max === 'number' && length > field.max ? 'max' : null
|
||||
}
|
||||
|
||||
function lengthCounter(field: BuiltinField, value: unknown): FieldCounter | null {
|
||||
if (typeof field.max !== 'number') {
|
||||
return null
|
||||
}
|
||||
const length = charCount(asText(value))
|
||||
return { text: `${length} / ${field.max}`, over: length > field.max }
|
||||
}
|
||||
|
||||
function textHelp(field: BuiltinField) {
|
||||
return field.unique ? uniqueHelp(field) : ''
|
||||
}
|
||||
|
||||
function numberViolation(field: BuiltinField, value: unknown): EntryRule | null {
|
||||
if (isBlank(value)) {
|
||||
return field.required ? 'required' : null
|
||||
}
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return 'type'
|
||||
}
|
||||
if (typeof field.min === 'number' && value < field.min) {
|
||||
return 'min'
|
||||
}
|
||||
return typeof field.max === 'number' && value > field.max ? 'max' : null
|
||||
}
|
||||
|
||||
function dayBeyond(value: string, limit: Limit, side: 'min' | 'max') {
|
||||
if (typeof limit !== 'string' || limit === '') {
|
||||
return false
|
||||
}
|
||||
return side === 'min' ? value < limit : value > limit
|
||||
}
|
||||
|
||||
function dateViolation(field: BuiltinField, value: unknown): EntryRule | null {
|
||||
if (isBlank(value)) {
|
||||
return field.required ? 'required' : null
|
||||
}
|
||||
if (!isIsoDay(value)) {
|
||||
return 'type'
|
||||
}
|
||||
if (dayBeyond(value, field.min, 'min')) {
|
||||
return 'min'
|
||||
}
|
||||
return dayBeyond(value, field.max, 'max') ? 'max' : null
|
||||
}
|
||||
|
||||
function choiceViolation(field: BuiltinField, value: unknown): EntryRule | null {
|
||||
if (isBlank(value)) {
|
||||
return field.required ? 'required' : null
|
||||
}
|
||||
return (field.options ?? []).map(String).includes(String(value)) ? null : 'options'
|
||||
}
|
||||
|
||||
function booleanViolation(_field: BuiltinField, value: unknown): EntryRule | null {
|
||||
return isBlank(value) || typeof value === 'boolean' ? null : 'type'
|
||||
}
|
||||
|
||||
function booleanInitial(field: BuiltinField, stored: unknown, creating: boolean) {
|
||||
return asBoolean(creating ? field.default : stored)
|
||||
}
|
||||
|
||||
function booleanPayload(field: BuiltinField, value: unknown) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
return field.required ? false : null
|
||||
}
|
||||
|
||||
function noHelp() {
|
||||
return ''
|
||||
}
|
||||
|
||||
function noCounter() {
|
||||
return null
|
||||
}
|
||||
|
||||
function emptyAsText(value: unknown) {
|
||||
return isBlank(value) ? '' : String(value)
|
||||
}
|
||||
|
||||
export const ENTRY_EDITORS: Record<FieldType, EntryEditorSpec> = {
|
||||
text: {
|
||||
control: 'input',
|
||||
initial: textInitial,
|
||||
validate: lengthViolation,
|
||||
payload: textPayload,
|
||||
help: textHelp,
|
||||
counter: lengthCounter,
|
||||
},
|
||||
richtext: {
|
||||
control: 'markdown',
|
||||
initial: textInitial,
|
||||
validate: lengthViolation,
|
||||
payload: textPayload,
|
||||
help: () => t('Markdown. Switch to Preview to check the result.'),
|
||||
counter: lengthCounter,
|
||||
},
|
||||
number: {
|
||||
control: 'number',
|
||||
initial: (field, stored, creating) => asNumber(creating ? field.default : stored),
|
||||
validate: numberViolation,
|
||||
payload: (_field, value) => (isBlank(value) ? null : value),
|
||||
help: rangeHelp,
|
||||
counter: noCounter,
|
||||
},
|
||||
boolean: {
|
||||
control: 'switch',
|
||||
initial: booleanInitial,
|
||||
validate: booleanViolation,
|
||||
payload: booleanPayload,
|
||||
help: noHelp,
|
||||
counter: noCounter,
|
||||
},
|
||||
date: {
|
||||
control: 'date',
|
||||
initial: (field, stored, creating) => asText(creating ? field.default : stored),
|
||||
validate: dateViolation,
|
||||
payload: (_field, value) => (isBlank(value) ? null : emptyAsText(value)),
|
||||
help: rangeHelp,
|
||||
counter: noCounter,
|
||||
},
|
||||
select: {
|
||||
control: 'select',
|
||||
initial: (field, stored, creating) => asText(creating ? field.default : stored),
|
||||
validate: choiceViolation,
|
||||
payload: textPayload,
|
||||
help: noHelp,
|
||||
counter: noCounter,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
|
||||
import { describeCell, plainCellText } from '@/lib/entryCells'
|
||||
import { ruleMessage, type EntryRule } from '@/lib/entryMessages'
|
||||
import { isBlank } from '@/lib/entryValue'
|
||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||
|
||||
export type EntryValues = Record<string, unknown>
|
||||
|
||||
export interface FieldIssue {
|
||||
rule: EntryRule
|
||||
message: string
|
||||
}
|
||||
|
||||
export type EntryIssues = Record<string, FieldIssue>
|
||||
|
||||
export function initialValues(fields: BuiltinField[], stored: ContentEntry | null): EntryValues {
|
||||
return Object.fromEntries(
|
||||
fields.map((field) => [
|
||||
field.key,
|
||||
fieldTypeSpec(field.type).editor.initial(
|
||||
field,
|
||||
stored?.data[field.key],
|
||||
stored === null,
|
||||
),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
export function buildData(fields: BuiltinField[], values: EntryValues) {
|
||||
return Object.fromEntries(
|
||||
fields.map((field) => [
|
||||
field.key,
|
||||
fieldTypeSpec(field.type).editor.payload(field, values[field.key]),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
export function issueFor(field: BuiltinField, rule: EntryRule): FieldIssue {
|
||||
return { rule, message: ruleMessage(rule, field) }
|
||||
}
|
||||
|
||||
export function validateEntry(fields: BuiltinField[], values: EntryValues): EntryIssues {
|
||||
const issues: EntryIssues = {}
|
||||
for (const field of fields) {
|
||||
const rule = fieldTypeSpec(field.type).editor.validate(field, values[field.key])
|
||||
if (rule) {
|
||||
issues[field.key] = issueFor(field, rule)
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
export function firstIssueKey(fields: BuiltinField[], issues: EntryIssues) {
|
||||
return fields.find((field) => issues[field.key])?.key ?? null
|
||||
}
|
||||
|
||||
export function valuesDiffer(fields: BuiltinField[], current: EntryValues, saved: EntryValues) {
|
||||
return fields.some((field) => !Object.is(current[field.key], saved[field.key]))
|
||||
}
|
||||
|
||||
export function carriesDefault(field: BuiltinField, value: unknown) {
|
||||
if (isBlank(field.default)) {
|
||||
return false
|
||||
}
|
||||
const initial = fieldTypeSpec(field.type).editor.initial(field, undefined, true)
|
||||
return Object.is(initial, value)
|
||||
}
|
||||
|
||||
export function entryTitle(fields: BuiltinField[], entry: ContentEntry | null) {
|
||||
const first = fields[0]
|
||||
if (!first || !entry) {
|
||||
return entry ? t('Entry {0}', [entry.id]) : t('New entry')
|
||||
}
|
||||
const cell = describeCell(first, entry.data[first.key])
|
||||
return cell.kind === 'empty' ? t('Entry {0}', [entry.id]) : plainCellText(cell)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||
|
||||
export function groupFields(fields: BuiltinField[]) {
|
||||
const rows: BuiltinField[][] = []
|
||||
let index = 0
|
||||
while (index < fields.length) {
|
||||
const current = fields[index]!
|
||||
const next = fields[index + 1]
|
||||
const paired = next && fieldTypeSpec(current.type).pairs && fieldTypeSpec(next.type).pairs
|
||||
rows.push(paired ? [current, next] : [current])
|
||||
index += paired ? 2 : 1
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import { formatDayValue } from '@/lib/entryValue'
|
||||
|
||||
export type EntryRule = 'required' | 'type' | 'min' | 'max' | 'options' | 'unique'
|
||||
|
||||
function bound(field: BuiltinField, side: 'min' | 'max') {
|
||||
return field[side] ?? ''
|
||||
}
|
||||
|
||||
function choices(field: BuiltinField) {
|
||||
const options = (field.options ?? []).map(String)
|
||||
if (options.length < 2) {
|
||||
return options.join('')
|
||||
}
|
||||
return t('{0} or {1}', [options.slice(0, -1).join(', '), options[options.length - 1]!])
|
||||
}
|
||||
|
||||
function requiredMessage(field: BuiltinField) {
|
||||
switch (field.type) {
|
||||
case 'date':
|
||||
return t('Pick a date for {0}.', [field.label])
|
||||
case 'select':
|
||||
return t('Choose an option for {0}.', [field.label])
|
||||
case 'boolean':
|
||||
return t('Choose yes or no for {0}.', [field.label])
|
||||
default:
|
||||
return t('Fill in {0}.', [field.label])
|
||||
}
|
||||
}
|
||||
|
||||
function typeMessage(field: BuiltinField) {
|
||||
switch (field.type) {
|
||||
case 'number':
|
||||
return t('{0} must be a number.', [field.label])
|
||||
case 'boolean':
|
||||
return t('{0} must be yes or no.', [field.label])
|
||||
case 'date':
|
||||
return t('{0} must be a date, like 2026-11-21.', [field.label])
|
||||
case 'select':
|
||||
return t('{0} must be one of the choices.', [field.label])
|
||||
default:
|
||||
return t('{0} must be text.', [field.label])
|
||||
}
|
||||
}
|
||||
|
||||
function minMessage(field: BuiltinField) {
|
||||
const limit = bound(field, 'min')
|
||||
if (field.type === 'number') {
|
||||
return t('{0} must be at least {1}.', [field.label, String(limit)])
|
||||
}
|
||||
if (field.type === 'date') {
|
||||
return t("{0} can't be before {1}.", [field.label, formatDayValue(String(limit))])
|
||||
}
|
||||
return t('{0} needs at least {1} characters.', [field.label, String(limit)])
|
||||
}
|
||||
|
||||
function maxMessage(field: BuiltinField) {
|
||||
const limit = bound(field, 'max')
|
||||
if (field.type === 'number') {
|
||||
return t('{0} must be at most {1}.', [field.label, String(limit)])
|
||||
}
|
||||
if (field.type === 'date') {
|
||||
return t("{0} can't be after {1}.", [field.label, formatDayValue(String(limit))])
|
||||
}
|
||||
return t('{0} can be up to {1} characters.', [field.label, String(limit)])
|
||||
}
|
||||
|
||||
function uniqueMessage(field: BuiltinField) {
|
||||
switch (field.type) {
|
||||
case 'select':
|
||||
return t('Another entry already uses this choice for {0}.', [field.label])
|
||||
case 'date':
|
||||
return t('Another entry already uses this date for {0}.', [field.label])
|
||||
default:
|
||||
return t('Another entry already uses this {0}. Enter a different one.', [field.label])
|
||||
}
|
||||
}
|
||||
|
||||
export function ruleMessage(rule: EntryRule, field: BuiltinField) {
|
||||
switch (rule) {
|
||||
case 'required':
|
||||
return requiredMessage(field)
|
||||
case 'type':
|
||||
return typeMessage(field)
|
||||
case 'min':
|
||||
return minMessage(field)
|
||||
case 'max':
|
||||
return maxMessage(field)
|
||||
case 'options':
|
||||
return t('Choose one of: {0}.', [choices(field)])
|
||||
default:
|
||||
return uniqueMessage(field)
|
||||
}
|
||||
}
|
||||
|
||||
export function uniqueAlert(field: BuiltinField) {
|
||||
switch (field.type) {
|
||||
case 'select':
|
||||
return t('Another entry already uses this choice. Change it and save again.')
|
||||
case 'date':
|
||||
return t('Another entry already uses this date. Change it and save again.')
|
||||
default:
|
||||
return t('Another entry already uses this {0}. Change it and save again.', [
|
||||
field.label.toLowerCase(),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
export function uniqueHelp(field: BuiltinField) {
|
||||
return t('Each entry needs a different {0}.', [field.label.toLowerCase()])
|
||||
}
|
||||
|
||||
export function rangeHelp(field: BuiltinField) {
|
||||
const min = field.min
|
||||
const max = field.max
|
||||
if (field.type === 'date') {
|
||||
const from = min === null || min === undefined ? '' : formatDayValue(String(min))
|
||||
const until = max === null || max === undefined ? '' : formatDayValue(String(max))
|
||||
if (from && until) {
|
||||
return t('From {0} until {1}.', [from, until])
|
||||
}
|
||||
if (from) {
|
||||
return t('From {0}.', [from])
|
||||
}
|
||||
return until ? t('Until {0}.', [until]) : ''
|
||||
}
|
||||
if (typeof min === 'number' && typeof max === 'number') {
|
||||
return t('Between {0} and {1}.', [String(min), String(max)])
|
||||
}
|
||||
if (typeof min === 'number') {
|
||||
return t('At least {0}.', [String(min)])
|
||||
}
|
||||
return typeof max === 'number' ? t('At most {0}.', [String(max)]) : ''
|
||||
}
|
||||
|
||||
export function schemaChangedLine(key: string) {
|
||||
const name = key.replace(/_/g, ' ')
|
||||
return t(
|
||||
"A field was removed and a new required field, {0}, was added after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields.",
|
||||
[name.charAt(0).toUpperCase() + name.slice(1)],
|
||||
)
|
||||
}
|
||||
|
||||
export function entryLoadErrorLine(path: string) {
|
||||
return t('GET {0} failed. Check your connection and try again.', [path])
|
||||
}
|
||||
|
||||
export function typeLoadErrorLine(key: string) {
|
||||
return t('GET /v1/content-type/{0} failed. Check your connection and try again.', [key])
|
||||
}
|
||||
|
||||
export function entriesLoadErrorLine(key: string) {
|
||||
return t('GET /v1/content-type/{0}/entry failed. Check your connection and try again.', [key])
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ApiError } from '@/lib/apiError'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import type { EntryRule } from '@/lib/entryMessages'
|
||||
|
||||
const REFUSAL = /^invalid_entry:\s*([^:\s]+)\s*:\s*([a-z_]+)\s*$/
|
||||
const FIELD_RULES: readonly string[] = ['required', 'type', 'min', 'max', 'options', 'unique']
|
||||
const REFUSAL_STATUSES = [409, 422]
|
||||
const GONE_STATUSES = [403, 404]
|
||||
|
||||
export interface EntryRefusal {
|
||||
key: string
|
||||
rule: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export type RefusalOutcome =
|
||||
| { kind: 'field'; field: BuiltinField; rule: EntryRule; status: number }
|
||||
| { kind: 'schema'; key: string }
|
||||
| { kind: 'unknown' }
|
||||
|
||||
export function isGoneError(error: unknown) {
|
||||
return error instanceof ApiError && GONE_STATUSES.includes(error.status)
|
||||
}
|
||||
|
||||
export function parseEntryRefusal(error: unknown): EntryRefusal | null {
|
||||
if (!(error instanceof ApiError) || !REFUSAL_STATUSES.includes(error.status)) {
|
||||
return null
|
||||
}
|
||||
const match = REFUSAL.exec(error.body ?? '')
|
||||
return match ? { key: match[1]!, rule: match[2]!, status: error.status } : null
|
||||
}
|
||||
|
||||
export function classifyRefusal(
|
||||
fields: BuiltinField[],
|
||||
refusal: EntryRefusal | null,
|
||||
): RefusalOutcome {
|
||||
if (!refusal) {
|
||||
return { kind: 'unknown' }
|
||||
}
|
||||
const field = fields.find((candidate) => candidate.key === refusal.key)
|
||||
if (!field || !FIELD_RULES.includes(refusal.rule)) {
|
||||
return { kind: 'schema', key: refusal.key }
|
||||
}
|
||||
return {
|
||||
kind: 'field',
|
||||
field,
|
||||
rule: refusal.rule as EntryRule,
|
||||
status: refusal.status,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||
import { formatClock } from '@/lib/updateFormat'
|
||||
|
||||
export function formatEntryStamp(iso: string) {
|
||||
return `${formatAbsoluteDateFromIso(iso)}, ${formatClock(new Date(iso))}`
|
||||
}
|
||||
|
||||
export function formatSavedClock(iso: string) {
|
||||
return formatClock(new Date(iso))
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { formatAbsoluteDate } from '@/lib/relativeDate'
|
||||
|
||||
const ISO_DAY_PREFIX = /^(\d{4})-(\d{2})-(\d{2})/
|
||||
const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})$/
|
||||
|
||||
export function isBlank(value: unknown) {
|
||||
return value === null || value === undefined || value === ''
|
||||
}
|
||||
|
||||
export function charCount(text: string) {
|
||||
return Array.from(text).length
|
||||
}
|
||||
|
||||
export function isIsoDay(value: unknown): value is string {
|
||||
if (typeof value !== 'string') {
|
||||
return false
|
||||
}
|
||||
const match = ISO_DAY.exec(value)
|
||||
if (!match) {
|
||||
return false
|
||||
}
|
||||
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]
|
||||
const date = new Date(year, month - 1, day)
|
||||
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day
|
||||
}
|
||||
|
||||
export function asText(value: unknown) {
|
||||
return value === null || value === undefined ? '' : String(value)
|
||||
}
|
||||
|
||||
export function asNumber(value: unknown) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
|
||||
return Number(value)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function asBoolean(value: unknown) {
|
||||
return typeof value === 'boolean' ? value : null
|
||||
}
|
||||
|
||||
export function formatDayValue(value: string) {
|
||||
const match = ISO_DAY_PREFIX.exec(value)
|
||||
if (!match) {
|
||||
return value
|
||||
}
|
||||
return formatAbsoluteDate(new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])))
|
||||
}
|
||||
|
||||
export function freeSentinel(taken: string[], base = '__none__') {
|
||||
let sentinel = base
|
||||
while (taken.includes(sentinel)) {
|
||||
sentinel += '_'
|
||||
}
|
||||
return sentinel
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { t } from '@/i18n'
|
||||
import { ENTRY_EDITORS, type EntryEditorSpec } from '@/lib/entryEditors'
|
||||
|
||||
export const FIELD_TYPES = ['text', 'richtext', 'number', 'boolean', 'date', 'select'] as const
|
||||
|
||||
export type FieldType = (typeof FIELD_TYPES)[number]
|
||||
|
||||
export type FieldCellKind = 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'choice'
|
||||
|
||||
export interface FieldTypeSpec {
|
||||
icon: string
|
||||
label: () => string
|
||||
description: () => string
|
||||
cell: FieldCellKind
|
||||
narrowColumn: boolean
|
||||
alignEnd: boolean
|
||||
supportsUnique: boolean
|
||||
supportsLimits: boolean
|
||||
pairs: boolean
|
||||
editor: EntryEditorSpec
|
||||
}
|
||||
|
||||
const FALLBACK_TYPE: FieldType = 'text'
|
||||
|
||||
export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
|
||||
text: {
|
||||
icon: 'i-lucide-type',
|
||||
label: () => t('Text'),
|
||||
description: () => t('Short text, like a title or a name'),
|
||||
cell: 'text',
|
||||
narrowColumn: false,
|
||||
alignEnd: false,
|
||||
supportsUnique: true,
|
||||
supportsLimits: true,
|
||||
pairs: false,
|
||||
editor: ENTRY_EDITORS.text,
|
||||
},
|
||||
richtext: {
|
||||
icon: 'i-lucide-pilcrow',
|
||||
label: () => t('Rich text'),
|
||||
description: () => t('Long text with formatting, written in Markdown'),
|
||||
cell: 'richtext',
|
||||
narrowColumn: false,
|
||||
alignEnd: false,
|
||||
supportsUnique: false,
|
||||
supportsLimits: true,
|
||||
pairs: false,
|
||||
editor: ENTRY_EDITORS.richtext,
|
||||
},
|
||||
number: {
|
||||
icon: 'i-lucide-hash',
|
||||
label: () => t('Number'),
|
||||
description: () => t('Whole or decimal numbers'),
|
||||
cell: 'number',
|
||||
narrowColumn: true,
|
||||
alignEnd: true,
|
||||
supportsUnique: true,
|
||||
supportsLimits: true,
|
||||
pairs: true,
|
||||
editor: ENTRY_EDITORS.number,
|
||||
},
|
||||
boolean: {
|
||||
icon: 'i-lucide-toggle-left',
|
||||
label: () => t('Yes or no'),
|
||||
description: () => t('An on or off switch'),
|
||||
cell: 'boolean',
|
||||
narrowColumn: true,
|
||||
alignEnd: false,
|
||||
supportsUnique: false,
|
||||
supportsLimits: false,
|
||||
pairs: true,
|
||||
editor: ENTRY_EDITORS.boolean,
|
||||
},
|
||||
date: {
|
||||
icon: 'i-lucide-calendar',
|
||||
label: () => t('Date'),
|
||||
description: () => t('A calendar day, without a time'),
|
||||
cell: 'date',
|
||||
narrowColumn: false,
|
||||
alignEnd: false,
|
||||
supportsUnique: true,
|
||||
supportsLimits: true,
|
||||
pairs: true,
|
||||
editor: ENTRY_EDITORS.date,
|
||||
},
|
||||
select: {
|
||||
icon: 'i-lucide-list',
|
||||
label: () => t('Choice'),
|
||||
description: () => t('One option from a list you define'),
|
||||
cell: 'choice',
|
||||
narrowColumn: false,
|
||||
alignEnd: false,
|
||||
supportsUnique: true,
|
||||
supportsLimits: false,
|
||||
pairs: false,
|
||||
editor: ENTRY_EDITORS.select,
|
||||
},
|
||||
}
|
||||
|
||||
export function isFieldType(type: string): type is FieldType {
|
||||
return (FIELD_TYPES as readonly string[]).includes(type)
|
||||
}
|
||||
|
||||
export function fieldTypeSpec(type: string): FieldTypeSpec {
|
||||
return FIELD_TYPE_SPECS[isFieldType(type) ? type : FALLBACK_TYPE]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
const SAFE_LINK = /^(https?:|mailto:)/i
|
||||
|
||||
const renderer = new MarkdownIt({ html: false, linkify: false, breaks: false })
|
||||
renderer.disable('image')
|
||||
renderer.validateLink = (url) => SAFE_LINK.test(url.trim())
|
||||
|
||||
const defaultLinkOpen =
|
||||
renderer.renderer.rules.link_open ??
|
||||
((tokens, index, options, _env, self) => self.renderToken(tokens, index, options))
|
||||
|
||||
renderer.renderer.rules.link_open = (tokens, index, options, env, self) => {
|
||||
const token = tokens[index]!
|
||||
token.attrSet('target', '_blank')
|
||||
token.attrSet('rel', 'noopener noreferrer')
|
||||
return defaultLinkOpen(tokens, index, options, env, self)
|
||||
}
|
||||
|
||||
export function renderMarkdown(source: string) {
|
||||
return renderer.render(source)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export type MarkdownAction = 'bold' | 'italic' | 'heading' | 'link' | 'list'
|
||||
|
||||
export const MARKDOWN_ACTIONS: MarkdownAction[] = ['bold', 'italic', 'heading', 'link', 'list']
|
||||
|
||||
export interface MarkdownEdit {
|
||||
value: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
const LINK_PLACEHOLDER = 'url'
|
||||
|
||||
function wrap(value: string, start: number, end: number, mark: string): MarkdownEdit {
|
||||
const inner = value.slice(start, end)
|
||||
return {
|
||||
value: `${value.slice(0, start)}${mark}${inner}${mark}${value.slice(end)}`,
|
||||
start: start + mark.length,
|
||||
end: end + mark.length,
|
||||
}
|
||||
}
|
||||
|
||||
function lineStart(value: string, index: number) {
|
||||
return index === 0 ? 0 : value.lastIndexOf('\n', index - 1) + 1
|
||||
}
|
||||
|
||||
function prefixLines(value: string, start: number, end: number, prefix: string): MarkdownEdit {
|
||||
const from = lineStart(value, start)
|
||||
const block = value.slice(from, end)
|
||||
const lines = block.split('\n')
|
||||
const prefixed = lines.map((line) => `${prefix}${line}`).join('\n')
|
||||
return {
|
||||
value: `${value.slice(0, from)}${prefixed}${value.slice(end)}`,
|
||||
start: start + prefix.length,
|
||||
end: end + prefix.length * lines.length,
|
||||
}
|
||||
}
|
||||
|
||||
function link(value: string, start: number, end: number): MarkdownEdit {
|
||||
const inner = value.slice(start, end)
|
||||
const urlStart = start + inner.length + 3
|
||||
return {
|
||||
value: `${value.slice(0, start)}[${inner}](${LINK_PLACEHOLDER})${value.slice(end)}`,
|
||||
start: urlStart,
|
||||
end: urlStart + LINK_PLACEHOLDER.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyMarkdownAction(
|
||||
action: MarkdownAction,
|
||||
value: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): MarkdownEdit {
|
||||
switch (action) {
|
||||
case 'bold':
|
||||
return wrap(value, start, end, '**')
|
||||
case 'italic':
|
||||
return wrap(value, start, end, '_')
|
||||
case 'heading':
|
||||
return prefixLines(value, start, start, '## ')
|
||||
case 'link':
|
||||
return link(value, start, end)
|
||||
default:
|
||||
return prefixLines(value, start, end, '- ')
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from '@/i18n'
|
||||
import { fieldTypeLabel } from '@/lib/schemaFields'
|
||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||
import type { BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import { payloadField, type PayloadField } from '@/lib/schemaBuilderPayload'
|
||||
|
||||
@@ -151,7 +151,10 @@ function changeNotes(before: PayloadField, after: PayloadField) {
|
||||
}
|
||||
|
||||
function addNote(field: PayloadField) {
|
||||
const parts = [fieldTypeLabel(field.type), field.required ? t('required') : t('optional')]
|
||||
const parts = [
|
||||
fieldTypeSpec(field.type).label(),
|
||||
field.required ? t('required') : t('optional'),
|
||||
]
|
||||
if (field.default !== null) {
|
||||
parts.push(
|
||||
field.type === 'boolean'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BuiltinField, ContentType } from '@/composables/useContentTypes'
|
||||
import type { BuiltinField, ContentType } from '@/lib/contentTypes'
|
||||
import {
|
||||
FIELD_TYPES,
|
||||
createField,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const FIELD_TYPES = ['text', 'richtext', 'number', 'boolean', 'date', 'select'] as const
|
||||
import { FIELD_TYPE_SPECS, FIELD_TYPES, type FieldType } from '@/lib/fieldTypes'
|
||||
|
||||
export type FieldType = (typeof FIELD_TYPES)[number]
|
||||
export { FIELD_TYPES, type FieldType }
|
||||
export type KeyMode = 'auto' | 'manual'
|
||||
export type KeyDisplayMode = KeyMode | 'empty'
|
||||
export type BuilderStatus =
|
||||
@@ -104,11 +104,11 @@ export function changeFieldType(field: BuilderField, type: FieldType): BuilderFi
|
||||
}
|
||||
|
||||
export function supportsUnique(type: FieldType) {
|
||||
return type !== 'richtext' && type !== 'boolean'
|
||||
return FIELD_TYPE_SPECS[type].supportsUnique
|
||||
}
|
||||
|
||||
export function supportsLimits(type: FieldType) {
|
||||
return type === 'text' || type === 'richtext' || type === 'number' || type === 'date'
|
||||
return FIELD_TYPE_SPECS[type].supportsLimits
|
||||
}
|
||||
|
||||
export function hasDefaultValue(field: BuilderField) {
|
||||
|
||||
@@ -1,38 +1,9 @@
|
||||
import { t, formatNumber } from '@/i18n'
|
||||
import type { BuiltinField } from '@/composables/useContentTypes'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import { asNumber } from '@/lib/entryValue'
|
||||
|
||||
const RULE_SEPARATOR = ' · '
|
||||
|
||||
const FIELD_ICONS: Record<string, string> = {
|
||||
text: 'i-lucide-type',
|
||||
richtext: 'i-lucide-pilcrow',
|
||||
number: 'i-lucide-hash',
|
||||
boolean: 'i-lucide-toggle-left',
|
||||
date: 'i-lucide-calendar',
|
||||
select: 'i-lucide-list',
|
||||
}
|
||||
|
||||
export function fieldIcon(type: string) {
|
||||
return FIELD_ICONS[type] ?? FIELD_ICONS.text!
|
||||
}
|
||||
|
||||
export function fieldTypeLabel(type: string) {
|
||||
switch (type) {
|
||||
case 'richtext':
|
||||
return t('Rich text')
|
||||
case 'number':
|
||||
return t('Number')
|
||||
case 'boolean':
|
||||
return t('Yes or no')
|
||||
case 'date':
|
||||
return t('Date')
|
||||
case 'select':
|
||||
return t('Choice')
|
||||
default:
|
||||
return t('Text')
|
||||
}
|
||||
}
|
||||
|
||||
function present<T>(value: T | null | undefined): value is T {
|
||||
return value !== null && value !== undefined
|
||||
}
|
||||
@@ -78,10 +49,10 @@ function defaultRule(field: BuiltinField) {
|
||||
|
||||
function constraintRule(field: BuiltinField) {
|
||||
if (field.type === 'text' || field.type === 'richtext') {
|
||||
return lengthRule(field.min, field.max)
|
||||
return lengthRule(asNumber(field.min), asNumber(field.max))
|
||||
}
|
||||
if (field.type === 'number') {
|
||||
return rangeRule(field.min, field.max)
|
||||
return rangeRule(asNumber(field.min), asNumber(field.max))
|
||||
}
|
||||
if (field.type === 'select' && Array.isArray(field.options) && field.options.length > 0) {
|
||||
return t('{0} choices', [field.options.length])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t, formatNumber } from '@/i18n'
|
||||
import type { BuiltinType, ContentType } from '@/composables/useContentTypes'
|
||||
import type { BuiltinType, ContentType } from '@/lib/contentTypes'
|
||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||
|
||||
export const SCHEMA_TABLE_QUERY = '(min-width: 40rem)'
|
||||
|
||||
Reference in new issue
Block a user