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)
})
})
+12
View File
@@ -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
}
}
+8
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import type { ContentType } from '@/composables/useContentTypes'
import type { ContentType } from '@/lib/contentTypes'
export const COLLAPSED_LIMIT = 5
+3 -4
View File
@@ -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
View File
@@ -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) }
}
}
+66
View File
@@ -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'
}
+172
View File
@@ -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,
},
}
+77
View File
@@ -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)
}
+15
View File
@@ -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
}
+155
View File
@@ -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])
}
+50
View File
@@ -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,
}
}
+10
View File
@@ -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))
}
+59
View File
@@ -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
}
+106
View File
@@ -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]
}
+22
View File
@@ -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)
}
+66
View File
@@ -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, '- ')
}
}
+5 -2
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import type { BuiltinField, ContentType } from '@/composables/useContentTypes'
import type { BuiltinField, ContentType } from '@/lib/contentTypes'
import {
FIELD_TYPES,
createField,
+4 -4
View File
@@ -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) {
+4 -33
View File
@@ -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 -1
View File
@@ -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)'