feat(SPA): media field type
This commit is contained in:
1 parent
a2d596f4ca
commit
3e7fc9a4a5
57 files changed
+6109
-75
No files matched your search
@@ -9,6 +9,7 @@ const CONTROLS: Record<FieldType, string> = {
|
||||
boolean: 'switch',
|
||||
date: 'date',
|
||||
select: 'select',
|
||||
media: 'media',
|
||||
}
|
||||
|
||||
function editor(type: FieldType) {
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ContentType } from '@/composables/useContentTypes'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import type { ContentEntry } from '@/lib/contentTypes'
|
||||
import { cardFields, describeCell, plainCellText, thumbnailColumn } from '@/lib/entryCells'
|
||||
import { ENTRY_EDITORS } from '@/lib/entryEditors'
|
||||
import { buildData, entryTitle, initialValues, validateEntry } from '@/lib/entryForm'
|
||||
import { ruleMessage } from '@/lib/entryMessages'
|
||||
import { groupFields } from '@/lib/entryLayout'
|
||||
import { ApiError } from '@/lib/apiError'
|
||||
import { classifyRefusal, parseEntryRefusal } from '@/lib/entryServerError'
|
||||
import { FIELD_TYPES, fieldTypeSpec } from '@/lib/fieldTypes'
|
||||
import {
|
||||
imageButtonName,
|
||||
imageMeta,
|
||||
imageOfItem,
|
||||
isMediaId,
|
||||
isMissingImage,
|
||||
mediaOf,
|
||||
} from '@/lib/mediaField'
|
||||
import { diffFields, summaryLine } from '@/lib/schemaBuilderDiff'
|
||||
import { baselinePayload, fieldFromApi, formFromType } from '@/lib/schemaBuilderLoad'
|
||||
import { changeFieldType, createField } from '@/lib/schemaBuilderModel'
|
||||
import { buildPayload, payloadField } from '@/lib/schemaBuilderPayload'
|
||||
import { sampleEntry } from '@/lib/schemaBuilderSample'
|
||||
import { apiField, eventsFields } from './schemaFixtures'
|
||||
|
||||
const LIME = {
|
||||
id: 7,
|
||||
filename: 'lime-plaster-workshop.jpg',
|
||||
width: 1600,
|
||||
height: 900,
|
||||
size: 245760,
|
||||
thumbnail_url: '/media/lime-plaster-workshop.jpg',
|
||||
}
|
||||
const VECTOR = {
|
||||
id: 4,
|
||||
filename: 'logo-renew.svg',
|
||||
width: null,
|
||||
height: null,
|
||||
size: 8192,
|
||||
thumbnail_url: '/media/logo-renew.svg',
|
||||
}
|
||||
const MISSING = { id: 7, missing: true as const }
|
||||
|
||||
function mediaField(rest: object = {}) {
|
||||
return apiField('Thumbnail', 'thumbnail', 'media', { options: [], ...rest })
|
||||
}
|
||||
|
||||
const media = ENTRY_EDITORS.media
|
||||
|
||||
describe('media in the registry', () => {
|
||||
it('is the seventh type, after select', () => {
|
||||
expect(FIELD_TYPES).toHaveLength(7)
|
||||
expect(FIELD_TYPES[6]).toBe('media')
|
||||
expect(FIELD_TYPES[5]).toBe('select')
|
||||
})
|
||||
|
||||
it('describes itself', () => {
|
||||
const spec = fieldTypeSpec('media')
|
||||
|
||||
expect(spec.label()).toBe('Media')
|
||||
expect(spec.description()).toBe('One image from your media library')
|
||||
expect(spec.icon).toBe('i-lucide-image')
|
||||
expect(spec.editor.control).toBe('media')
|
||||
expect(spec.cell).toBe('thumbnail')
|
||||
expect(spec.narrowColumn).toBe(true)
|
||||
expect(spec.alignEnd).toBe(false)
|
||||
expect(spec.pairs).toBe(false)
|
||||
})
|
||||
|
||||
it('supports no unique, limits, default or choices', () => {
|
||||
const spec = fieldTypeSpec('media')
|
||||
|
||||
expect(spec.supportsUnique).toBe(false)
|
||||
expect(spec.supportsLimits).toBe(false)
|
||||
expect(spec.supportsDefault).toBe(false)
|
||||
expect(spec.supportsChoices).toBe(false)
|
||||
expect(spec.showsUnsupportedOptions).toBe(false)
|
||||
expect(spec.builderHint?.()).toBe(
|
||||
'Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version.',
|
||||
)
|
||||
})
|
||||
|
||||
it.each(FIELD_TYPES.filter((type) => type !== 'media'))(
|
||||
'%s keeps its builder options and has no hint',
|
||||
(type) => {
|
||||
const spec = fieldTypeSpec(type)
|
||||
|
||||
expect(spec.supportsDefault).toBe(true)
|
||||
expect(spec.showsUnsupportedOptions).toBe(true)
|
||||
expect(spec.builderHint).toBeNull()
|
||||
expect(spec.supportsChoices).toBe(type === 'select')
|
||||
},
|
||||
)
|
||||
|
||||
it('is not the text fallback for a loaded field', () => {
|
||||
expect(fieldTypeSpec('media')).not.toBe(fieldTypeSpec('text'))
|
||||
const field = fieldFromApi(mediaField(), 1)
|
||||
|
||||
expect(field.type).toBe('media')
|
||||
})
|
||||
|
||||
it('starts empty on create and keeps the stored id on edit', () => {
|
||||
const field = mediaField()
|
||||
|
||||
expect(media.initial(field, undefined, true)).toBeNull()
|
||||
expect(media.initial(mediaField({ default: 7 }), 3, true)).toBeNull()
|
||||
expect(media.initial(field, 7, false)).toBe(7)
|
||||
expect(media.initial(field, null, false)).toBeNull()
|
||||
expect(media.initial(field, undefined, false)).toBeNull()
|
||||
expect(media.initial(field, 'x', false)).toBeNull()
|
||||
})
|
||||
|
||||
it('has one help line and no counter', () => {
|
||||
expect(media.help(mediaField())).toBe('One image from your library.')
|
||||
expect(media.counter(mediaField(), 7)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('client validation of a media value', () => {
|
||||
it.each([
|
||||
['required', null, 'required'],
|
||||
['required', undefined, 'required'],
|
||||
['optional', null, null],
|
||||
['optional', 7, null],
|
||||
['required', 7, null],
|
||||
['optional', 0, 'type'],
|
||||
['optional', -1, 'type'],
|
||||
['optional', 7.5, 'type'],
|
||||
['optional', '7', 'type'],
|
||||
['optional', true, 'type'],
|
||||
['optional', 4294967296, 'type'],
|
||||
['optional', 4294967295, null],
|
||||
] as const)('%s holding %j gives %s', (required, value, rule) => {
|
||||
const field = mediaField({ required: required === 'required' })
|
||||
|
||||
expect(media.validate(field, value)).toBe(rule)
|
||||
})
|
||||
|
||||
it('reads a required field whose image is gone as required', () => {
|
||||
const field = mediaField({ required: true })
|
||||
|
||||
expect(media.validate(field, 7, { image: MISSING })).toBe('required')
|
||||
expect(media.validate(field, 7, { image: LIME })).toBeNull()
|
||||
})
|
||||
|
||||
it('lets an optional field keep a missing image', () => {
|
||||
expect(media.validate(mediaField(), 7, { image: MISSING })).toBeNull()
|
||||
})
|
||||
|
||||
it('hands the image of each field to the validation of the form', () => {
|
||||
const field = mediaField({ required: true })
|
||||
|
||||
expect(validateEntry([field], { thumbnail: 7 }, { thumbnail: MISSING })).toMatchObject({
|
||||
thumbnail: { rule: 'required', message: 'Pick an image for Thumbnail.' },
|
||||
})
|
||||
expect(validateEntry([field], { thumbnail: 7 }, { thumbnail: LIME })).toEqual({})
|
||||
expect(validateEntry([field], { thumbnail: null })).toMatchObject({
|
||||
thumbnail: { rule: 'required' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('messages of a media field', () => {
|
||||
const field = mediaField()
|
||||
|
||||
it.each([
|
||||
['required', 'Pick an image for Thumbnail.'],
|
||||
['type', 'Thumbnail must be an image from the library.'],
|
||||
] as const)('the rule %s reads "%s"', (rule, message) => {
|
||||
expect(ruleMessage(rule, field)).toBe(message)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['invalid_entry: thumbnail: required', 'required'],
|
||||
['invalid_entry: thumbnail: type', 'type'],
|
||||
])('maps the server answer %s to the field', (body, rule) => {
|
||||
const refusal = parseEntryRefusal(new ApiError(422, undefined, body))
|
||||
|
||||
expect(classifyRefusal([field], refusal)).toMatchObject({ kind: 'field', field, rule })
|
||||
})
|
||||
|
||||
it('maps an unknown media key to a schema change', () => {
|
||||
const refusal = parseEntryRefusal(
|
||||
new ApiError(422, undefined, 'invalid_entry: thumbnail: unknown_field'),
|
||||
)
|
||||
|
||||
expect(classifyRefusal([field], refusal)).toEqual({ kind: 'schema', key: 'thumbnail' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('payload of a media value', () => {
|
||||
it.each([
|
||||
[7, 7],
|
||||
[null, null],
|
||||
[undefined, null],
|
||||
] as const)('sends %j as %j', (value, sent) => {
|
||||
expect(media.payload(mediaField(), value)).toBe(sent)
|
||||
})
|
||||
|
||||
it('keeps the stored id of a missing image and never drops the key', () => {
|
||||
const fields = [apiField('Title', 'title', 'text'), mediaField()]
|
||||
const entry = {
|
||||
id: 1,
|
||||
content_type: 'events',
|
||||
data: { title: 'x', thumbnail: 7 },
|
||||
media: { thumbnail: MISSING },
|
||||
} as unknown as ContentEntry
|
||||
const values = initialValues(fields, entry)
|
||||
|
||||
expect(buildData(fields, values)).toEqual({ title: 'x', thumbnail: 7 })
|
||||
expect(buildData(fields, initialValues(fields, null))).toEqual({
|
||||
title: null,
|
||||
thumbnail: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the picked id as a JSON number', () => {
|
||||
const sent = JSON.stringify(buildData([mediaField()], { thumbnail: 8 }))
|
||||
|
||||
expect(sent).toBe('{"thumbnail":8}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('media ids', () => {
|
||||
it.each([
|
||||
[1, true],
|
||||
[4294967295, true],
|
||||
[0, false],
|
||||
[-1, false],
|
||||
[7.5, false],
|
||||
['7', false],
|
||||
[null, false],
|
||||
[4294967296, false],
|
||||
])('%j is an id: %s', (value, expected) => {
|
||||
expect(isMediaId(value)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('media summaries', () => {
|
||||
it('reads the map of an entry by field key', () => {
|
||||
const entry = { media: { thumbnail: LIME, cover: MISSING } } as unknown as ContentEntry
|
||||
|
||||
expect(mediaOf(entry, 'thumbnail')).toEqual(LIME)
|
||||
expect(isMissingImage(mediaOf(entry, 'cover')!)).toBe(true)
|
||||
expect(mediaOf(entry, 'absent')).toBeNull()
|
||||
expect(mediaOf({} as ContentEntry, 'thumbnail')).toBeNull()
|
||||
expect(mediaOf(null, 'thumbnail')).toBeNull()
|
||||
})
|
||||
|
||||
it('describes an image', () => {
|
||||
expect(imageMeta(LIME)).toBe('1600 × 900 · 240 KB')
|
||||
expect(imageMeta(VECTOR)).toBe('Vector · 8 KB')
|
||||
expect(imageButtonName(LIME)).toBe(
|
||||
'Change image, lime-plaster-workshop.jpg, 1600 by 900, 240 KB',
|
||||
)
|
||||
expect(imageButtonName(VECTOR)).toBe('Change image, logo-renew.svg, Vector, 8 KB')
|
||||
})
|
||||
|
||||
it('builds a summary from a library item without a request', () => {
|
||||
const item = {
|
||||
id: 8,
|
||||
filename: 'bathroom-tiles.png',
|
||||
url: '/media/bathroom-tiles.png',
|
||||
size: 51200,
|
||||
width: 800,
|
||||
height: 600,
|
||||
} as MediaItem
|
||||
|
||||
expect(imageOfItem(item)).toEqual({
|
||||
id: 8,
|
||||
filename: 'bathroom-tiles.png',
|
||||
width: 800,
|
||||
height: 600,
|
||||
size: 51200,
|
||||
thumbnail_url: '/media/bathroom-tiles.png',
|
||||
})
|
||||
expect(imageMeta(imageOfItem(item))).toBe('800 × 600 · 50 KB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('media in the list', () => {
|
||||
const field = mediaField()
|
||||
|
||||
it('describes a cell from the item own media object', () => {
|
||||
expect(describeCell(field, 7, LIME)).toEqual({
|
||||
kind: 'thumbnail',
|
||||
text: LIME.filename,
|
||||
image: LIME,
|
||||
})
|
||||
expect(describeCell(field, 7, MISSING)).toEqual({
|
||||
kind: 'thumbnail',
|
||||
text: '',
|
||||
image: MISSING,
|
||||
})
|
||||
expect(describeCell(field, 7)).toEqual({ kind: 'thumbnail', text: '', image: MISSING })
|
||||
expect(describeCell(field, null).kind).toBe('empty')
|
||||
expect(describeCell(field, undefined).kind).toBe('empty')
|
||||
})
|
||||
|
||||
it('reads a plain text of dash for none and never the id', () => {
|
||||
expect(plainCellText(describeCell(field, null))).toBe('–')
|
||||
expect(plainCellText(describeCell(field, 7, MISSING))).toBe('–')
|
||||
expect(plainCellText(describeCell(field, 7, MISSING))).not.toContain('7')
|
||||
expect(plainCellText(describeCell(field, 7, LIME))).toBe(LIME.filename)
|
||||
})
|
||||
|
||||
it('picks a leading thumbnail and keeps the card text fields', () => {
|
||||
const title = apiField('Title', 'title', 'text')
|
||||
const starts = apiField('Starts on', 'starts_on', 'date')
|
||||
const category = apiField('Category', 'category', 'select')
|
||||
|
||||
expect(cardFields([title, field, starts, category])).toEqual({
|
||||
first: title,
|
||||
second: starts,
|
||||
choice: category,
|
||||
})
|
||||
expect(cardFields([field, starts])).toMatchObject({ first: field, second: starts })
|
||||
expect(thumbnailColumn([title, field, starts])).toBe(field)
|
||||
expect(thumbnailColumn([title, starts])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('titles and layout', () => {
|
||||
const entry = {
|
||||
id: 42,
|
||||
content_type: 'events',
|
||||
data: { thumbnail: 7 },
|
||||
media: { thumbnail: LIME },
|
||||
} as unknown as ContentEntry
|
||||
|
||||
it('never uses a media value as the title of an entry', () => {
|
||||
expect(entryTitle([mediaField()], entry)).toBe('Entry 42')
|
||||
expect(entryTitle([mediaField(), apiField('Title', 'title', 'text')], entry)).toBe(
|
||||
'Entry 42',
|
||||
)
|
||||
})
|
||||
|
||||
it('never pairs a media field with a neighbour', () => {
|
||||
const flag = apiField('Online', 'online', 'boolean')
|
||||
const number = apiField('Capacity', 'capacity', 'number')
|
||||
const date = apiField('Starts on', 'starts_on', 'date')
|
||||
|
||||
for (const before of [flag, number, date]) {
|
||||
const rows = groupFields([before, mediaField(), apiField('Ends on', 'ends_on', 'date')])
|
||||
|
||||
expect(rows.map((row) => row.length)).toEqual([1, 1, 1])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('media in the schema builder', () => {
|
||||
const events: ContentType = {
|
||||
id: 1,
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
icon: null,
|
||||
fields: [...eventsFields, mediaField()],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
}
|
||||
|
||||
it('sends the exact body of a media field', () => {
|
||||
const field = createField(1, { label: 'Thumbnail', key: 'thumbnail', type: 'media' })
|
||||
|
||||
expect(payloadField(field)).toEqual({
|
||||
key: 'thumbnail',
|
||||
label: 'Thumbnail',
|
||||
type: 'media',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
options: [],
|
||||
})
|
||||
expect(payloadField({ ...field, required: true }).required).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores stale settings of another type when sending', () => {
|
||||
const stale = createField(1, {
|
||||
label: 'Thumbnail',
|
||||
key: 'thumbnail',
|
||||
type: 'media',
|
||||
unique: true,
|
||||
defaultValue: 'x',
|
||||
min: '3',
|
||||
max: '9',
|
||||
options: ['a'],
|
||||
})
|
||||
|
||||
expect(payloadField(stale)).toMatchObject({
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
options: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('round-trips a loaded media field with no change', () => {
|
||||
let next = 1
|
||||
const form = formFromType(events, () => next++)
|
||||
const baseline = baselinePayload(events)
|
||||
|
||||
expect(form.fields[7]!.type).toBe('media')
|
||||
expect(buildPayload(form).fields).toEqual(baseline)
|
||||
expect(diffFields(baseline, form.fields, 37)).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['text', 'number', 'select', 'richtext'] as const)(
|
||||
'resets unique, default, limits and options changing a new %s row to media and back',
|
||||
(from) => {
|
||||
const row = createField(1, {
|
||||
label: 'Thumbnail',
|
||||
key: 'thumbnail',
|
||||
required: true,
|
||||
type: from,
|
||||
unique: true,
|
||||
defaultValue: 'x',
|
||||
min: '1',
|
||||
max: '80',
|
||||
options: ['a', 'b'],
|
||||
})
|
||||
|
||||
const toMedia = changeFieldType(row, 'media')
|
||||
|
||||
expect(toMedia).toMatchObject({
|
||||
label: 'Thumbnail',
|
||||
key: 'thumbnail',
|
||||
required: true,
|
||||
type: 'media',
|
||||
unique: false,
|
||||
defaultValue: '',
|
||||
min: '',
|
||||
max: '',
|
||||
options: [],
|
||||
})
|
||||
expect(changeFieldType(toMedia, 'number')).toMatchObject({
|
||||
type: 'number',
|
||||
min: '',
|
||||
max: '',
|
||||
defaultValue: '',
|
||||
required: true,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('previews a media value as null', () => {
|
||||
const field = createField(1, { label: 'Thumbnail', key: 'thumbnail', type: 'media' })
|
||||
|
||||
expect(sampleEntry('events', [field]).data.thumbnail).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('review of a media field', () => {
|
||||
const events: ContentType = {
|
||||
id: 1,
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
icon: null,
|
||||
fields: eventsFields,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
}
|
||||
const baseline = baselinePayload(events)
|
||||
|
||||
function loaded() {
|
||||
let next = 1
|
||||
return formFromType(events, () => next++).fields
|
||||
}
|
||||
|
||||
function withThumbnail(rest: object = {}) {
|
||||
return [
|
||||
...loaded(),
|
||||
createField(99, { label: 'Thumbnail', key: 'thumbnail', type: 'media', ...rest }),
|
||||
]
|
||||
}
|
||||
|
||||
it('flags a required media field added to a type with entries as impossible', () => {
|
||||
const [change] = diffFields(baseline, withThumbnail({ required: true }), 12)
|
||||
|
||||
expect(change).toMatchObject({ kind: 'add', key: 'thumbnail', flag: 'cannotApply' })
|
||||
expect(change!.notes[0]!.text).toBe(
|
||||
"Media, required. A media field can't have a default, so the 12 existing entries would have no image. Make Thumbnail optional to add it.",
|
||||
)
|
||||
})
|
||||
|
||||
it('words the single entry case apart', () => {
|
||||
const [change] = diffFields(baseline, withThumbnail({ required: true }), 1)
|
||||
|
||||
expect(change!.notes[0]!.text).toBe(
|
||||
"Media, required. A media field can't have a default, so the existing entry would have no image. Make Thumbnail optional to add it.",
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[12, 'Media, optional. 12 entries are unaffected.'],
|
||||
[1, 'Media, optional. 1 entry is unaffected.'],
|
||||
[0, 'Media, optional.'],
|
||||
])('reads the clean row for %i entries', (total, text) => {
|
||||
const [change] = diffFields(baseline, withThumbnail(), total)
|
||||
|
||||
expect(change!.flag).toBeNull()
|
||||
expect(change!.notes[0]!.text).toBe(text)
|
||||
})
|
||||
|
||||
it('keeps the add note of the other types unchanged by the count', () => {
|
||||
const added = [...loaded(), createField(99, { label: 'Speaker', key: 'speaker' })]
|
||||
|
||||
expect(diffFields(baseline, added, 12)[0]!.notes[0]!.text).toBe('Text, optional.')
|
||||
expect(diffFields(baseline, added)[0]!.notes[0]!.text).toBe('Text, optional.')
|
||||
})
|
||||
|
||||
it('keeps the summary line', () => {
|
||||
expect(summaryLine(diffFields(baseline, withThumbnail({ required: true }), 12))).toBe(
|
||||
'Add 1 field',
|
||||
)
|
||||
})
|
||||
|
||||
it('flags making a saved optional media field required as one that may not fit', () => {
|
||||
const saved: ContentType = { ...events, fields: [...eventsFields, mediaField()] }
|
||||
const base = baselinePayload(saved)
|
||||
let next = 1
|
||||
const fields = formFromType(saved, () => next++).fields.map((field) =>
|
||||
field.key === 'thumbnail' ? { ...field, required: true } : field,
|
||||
)
|
||||
|
||||
const [change] = diffFields(base, fields, 12)
|
||||
|
||||
expect(change).toMatchObject({ kind: 'change', flag: 'mayNotFit' })
|
||||
expect(change!.notes[0]).toMatchObject({
|
||||
text: 'Now required.',
|
||||
hint: 'An entry may have no value.',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { MediaMap } from '@/lib/mediaField'
|
||||
|
||||
export interface BuiltinField {
|
||||
key: string
|
||||
label: string
|
||||
@@ -24,6 +26,7 @@ export interface ContentEntry {
|
||||
id: number
|
||||
content_type: string
|
||||
data: Record<string, unknown>
|
||||
media?: MediaMap
|
||||
created_by: number
|
||||
updated_by: number
|
||||
created_at: string
|
||||
|
||||
@@ -2,6 +2,7 @@ import { formatNumber, t } from '@/i18n'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import { fieldTypeSpec, type FieldCellKind } from '@/lib/fieldTypes'
|
||||
import { formatDayValue } from '@/lib/entryValue'
|
||||
import { isMissingImage, type MediaSummary } from '@/lib/mediaField'
|
||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||
|
||||
export { formatDayValue }
|
||||
@@ -12,6 +13,7 @@ export interface CellView {
|
||||
kind: CellKind
|
||||
text: string
|
||||
yes?: boolean
|
||||
image?: MediaSummary
|
||||
}
|
||||
|
||||
export const EMPTY_TEXT = '–'
|
||||
@@ -37,12 +39,23 @@ export function richTextFirstLine(markdown: string) {
|
||||
return ''
|
||||
}
|
||||
|
||||
export function describeCell(field: BuiltinField, value: unknown): CellView {
|
||||
function thumbnailCell(value: unknown, image: MediaSummary | undefined): CellView {
|
||||
const shown = image ?? { id: Number(value), missing: true as const }
|
||||
return {
|
||||
kind: 'thumbnail',
|
||||
text: isMissingImage(shown) ? '' : shown.filename,
|
||||
image: shown,
|
||||
}
|
||||
}
|
||||
|
||||
export function describeCell(field: BuiltinField, value: unknown, image?: MediaSummary): CellView {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return EMPTY
|
||||
}
|
||||
const kind = fieldTypeSpec(field.type).cell
|
||||
switch (kind) {
|
||||
case 'thumbnail':
|
||||
return thumbnailCell(value, image)
|
||||
case 'boolean':
|
||||
return { kind, text: '', yes: value === true }
|
||||
case 'number':
|
||||
@@ -75,7 +88,7 @@ export function plainCellText(cell: CellView) {
|
||||
if (cell.kind === 'boolean') {
|
||||
return cell.yes ? t('Yes') : t('No')
|
||||
}
|
||||
return cell.text
|
||||
return cell.text || EMPTY_TEXT
|
||||
}
|
||||
|
||||
export interface CardFields {
|
||||
@@ -84,8 +97,17 @@ export interface CardFields {
|
||||
choice?: BuiltinField
|
||||
}
|
||||
|
||||
function isThumbnail(field: BuiltinField) {
|
||||
return fieldTypeSpec(field.type).cell === 'thumbnail'
|
||||
}
|
||||
|
||||
export function thumbnailColumn(columns: BuiltinField[]) {
|
||||
return columns.find(isThumbnail)
|
||||
}
|
||||
|
||||
export function cardFields(fields: BuiltinField[]): CardFields {
|
||||
const [first, second] = fields
|
||||
const [first, ...rest] = fields
|
||||
const second = rest.find((field) => !isThumbnail(field))
|
||||
const choice = fields.find((field) => field.type === 'select')
|
||||
return { first, second, choice: choice === first ? undefined : choice }
|
||||
}
|
||||
@@ -3,18 +3,23 @@ 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'
|
||||
import { isMediaId, isMissingImage, type MediaSummary } from '@/lib/mediaField'
|
||||
|
||||
export type FieldControl = 'input' | 'markdown' | 'number' | 'switch' | 'date' | 'select'
|
||||
export type FieldControl = 'input' | 'markdown' | 'number' | 'switch' | 'date' | 'select' | 'media'
|
||||
|
||||
export interface FieldCounter {
|
||||
text: string
|
||||
over: boolean
|
||||
}
|
||||
|
||||
export interface EntryContext {
|
||||
image: MediaSummary | null
|
||||
}
|
||||
|
||||
export interface EntryEditorSpec {
|
||||
control: FieldControl
|
||||
initial: (field: BuiltinField, stored: unknown, creating: boolean) => unknown
|
||||
validate: (field: BuiltinField, value: unknown) => EntryRule | null
|
||||
validate: (field: BuiltinField, value: unknown, context?: EntryContext) => EntryRule | null
|
||||
payload: (field: BuiltinField, value: unknown) => unknown
|
||||
help: (field: BuiltinField) => string
|
||||
counter: (field: BuiltinField, value: unknown) => FieldCounter | null
|
||||
@@ -108,6 +113,21 @@ function booleanPayload(field: BuiltinField, value: unknown) {
|
||||
return field.required ? false : null
|
||||
}
|
||||
|
||||
function mediaViolation(
|
||||
field: BuiltinField,
|
||||
value: unknown,
|
||||
context?: EntryContext,
|
||||
): EntryRule | null {
|
||||
if (value === null || value === undefined) {
|
||||
return field.required ? 'required' : null
|
||||
}
|
||||
if (!isMediaId(value)) {
|
||||
return 'type'
|
||||
}
|
||||
const gone = context?.image ? isMissingImage(context.image) : false
|
||||
return field.required && gone ? 'required' : null
|
||||
}
|
||||
|
||||
function noHelp() {
|
||||
return ''
|
||||
}
|
||||
@@ -169,4 +189,12 @@ export const ENTRY_EDITORS: Record<FieldType, EntryEditorSpec> = {
|
||||
help: noHelp,
|
||||
counter: noCounter,
|
||||
},
|
||||
media: {
|
||||
control: 'media',
|
||||
initial: (_field, stored, creating) => (!creating && isMediaId(stored) ? stored : null),
|
||||
validate: mediaViolation,
|
||||
payload: (_field, value) => (value === undefined ? null : value),
|
||||
help: () => t('One image from your library.'),
|
||||
counter: noCounter,
|
||||
},
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { describeCell, plainCellText } from '@/lib/entryCells'
|
||||
import { ruleMessage, type EntryRule } from '@/lib/entryMessages'
|
||||
import { isBlank } from '@/lib/entryValue'
|
||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||
import { mediaOf, type MediaMap } from '@/lib/mediaField'
|
||||
|
||||
export type EntryValues = Record<string, unknown>
|
||||
|
||||
@@ -27,6 +28,17 @@ export function initialValues(fields: BuiltinField[], stored: ContentEntry | nul
|
||||
)
|
||||
}
|
||||
|
||||
export function initialImages(fields: BuiltinField[], stored: ContentEntry | null): MediaMap {
|
||||
const images: MediaMap = {}
|
||||
for (const field of fields) {
|
||||
const summary = mediaOf(stored, field.key)
|
||||
if (summary && stored?.data[field.key] === summary.id) {
|
||||
images[field.key] = summary
|
||||
}
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
export function buildData(fields: BuiltinField[], values: EntryValues) {
|
||||
return Object.fromEntries(
|
||||
fields.map((field) => [
|
||||
@@ -40,10 +52,16 @@ export function issueFor(field: BuiltinField, rule: EntryRule): FieldIssue {
|
||||
return { rule, message: ruleMessage(rule, field) }
|
||||
}
|
||||
|
||||
export function validateEntry(fields: BuiltinField[], values: EntryValues): EntryIssues {
|
||||
export function validateEntry(
|
||||
fields: BuiltinField[],
|
||||
values: EntryValues,
|
||||
images: MediaMap = {},
|
||||
): EntryIssues {
|
||||
const issues: EntryIssues = {}
|
||||
for (const field of fields) {
|
||||
const rule = fieldTypeSpec(field.type).editor.validate(field, values[field.key])
|
||||
const rule = fieldTypeSpec(field.type).editor.validate(field, values[field.key], {
|
||||
image: images[field.key] ?? null,
|
||||
})
|
||||
if (rule) {
|
||||
issues[field.key] = issueFor(field, rule)
|
||||
}
|
||||
@@ -73,5 +91,7 @@ export function entryTitle(fields: BuiltinField[], entry: ContentEntry | null) {
|
||||
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)
|
||||
return cell.kind === 'empty' || cell.kind === 'thumbnail'
|
||||
? t('Entry {0}', [entry.id])
|
||||
: plainCellText(cell)
|
||||
}
|
||||
@@ -24,6 +24,8 @@ function requiredMessage(field: BuiltinField) {
|
||||
return t('Choose an option for {0}.', [field.label])
|
||||
case 'boolean':
|
||||
return t('Choose yes or no for {0}.', [field.label])
|
||||
case 'media':
|
||||
return t('Pick an image for {0}.', [field.label])
|
||||
default:
|
||||
return t('Fill in {0}.', [field.label])
|
||||
}
|
||||
@@ -39,6 +41,8 @@ function typeMessage(field: BuiltinField) {
|
||||
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])
|
||||
case 'media':
|
||||
return t('{0} must be an image from the library.', [field.label])
|
||||
default:
|
||||
return t('{0} must be text.', [field.label])
|
||||
}
|
||||
@@ -134,6 +138,13 @@ export function rangeHelp(field: BuiltinField) {
|
||||
return typeof max === 'number' ? t('At most {0}.', [String(max)]) : ''
|
||||
}
|
||||
|
||||
export function mediaChangedLine(label: string) {
|
||||
return t(
|
||||
"The {0} field was changed after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields.",
|
||||
[label],
|
||||
)
|
||||
}
|
||||
|
||||
export function schemaChangedLine(key: string) {
|
||||
const name = key.replace(/_/g, ' ')
|
||||
return t(
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
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 const FIELD_TYPES = [
|
||||
'text',
|
||||
'richtext',
|
||||
'number',
|
||||
'boolean',
|
||||
'date',
|
||||
'select',
|
||||
'media',
|
||||
] as const
|
||||
|
||||
export type FieldType = (typeof FIELD_TYPES)[number]
|
||||
|
||||
export type FieldCellKind = 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'choice'
|
||||
export type FieldCellKind =
|
||||
| 'text'
|
||||
| 'richtext'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'date'
|
||||
| 'choice'
|
||||
| 'thumbnail'
|
||||
|
||||
export interface FieldTypeSpec {
|
||||
icon: string
|
||||
@@ -16,6 +31,10 @@ export interface FieldTypeSpec {
|
||||
alignEnd: boolean
|
||||
supportsUnique: boolean
|
||||
supportsLimits: boolean
|
||||
supportsDefault: boolean
|
||||
supportsChoices: boolean
|
||||
showsUnsupportedOptions: boolean
|
||||
builderHint: (() => string) | null
|
||||
pairs: boolean
|
||||
editor: EntryEditorSpec
|
||||
}
|
||||
@@ -32,6 +51,10 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
|
||||
alignEnd: false,
|
||||
supportsUnique: true,
|
||||
supportsLimits: true,
|
||||
supportsDefault: true,
|
||||
supportsChoices: false,
|
||||
showsUnsupportedOptions: true,
|
||||
builderHint: null,
|
||||
pairs: false,
|
||||
editor: ENTRY_EDITORS.text,
|
||||
},
|
||||
@@ -44,6 +67,10 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
|
||||
alignEnd: false,
|
||||
supportsUnique: false,
|
||||
supportsLimits: true,
|
||||
supportsDefault: true,
|
||||
supportsChoices: false,
|
||||
showsUnsupportedOptions: true,
|
||||
builderHint: null,
|
||||
pairs: false,
|
||||
editor: ENTRY_EDITORS.richtext,
|
||||
},
|
||||
@@ -56,6 +83,10 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
|
||||
alignEnd: true,
|
||||
supportsUnique: true,
|
||||
supportsLimits: true,
|
||||
supportsDefault: true,
|
||||
supportsChoices: false,
|
||||
showsUnsupportedOptions: true,
|
||||
builderHint: null,
|
||||
pairs: true,
|
||||
editor: ENTRY_EDITORS.number,
|
||||
},
|
||||
@@ -68,6 +99,10 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
|
||||
alignEnd: false,
|
||||
supportsUnique: false,
|
||||
supportsLimits: false,
|
||||
supportsDefault: true,
|
||||
supportsChoices: false,
|
||||
showsUnsupportedOptions: true,
|
||||
builderHint: null,
|
||||
pairs: true,
|
||||
editor: ENTRY_EDITORS.boolean,
|
||||
},
|
||||
@@ -80,6 +115,10 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
|
||||
alignEnd: false,
|
||||
supportsUnique: true,
|
||||
supportsLimits: true,
|
||||
supportsDefault: true,
|
||||
supportsChoices: false,
|
||||
showsUnsupportedOptions: true,
|
||||
builderHint: null,
|
||||
pairs: true,
|
||||
editor: ENTRY_EDITORS.date,
|
||||
},
|
||||
@@ -92,9 +131,32 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
|
||||
alignEnd: false,
|
||||
supportsUnique: true,
|
||||
supportsLimits: false,
|
||||
supportsDefault: true,
|
||||
supportsChoices: true,
|
||||
showsUnsupportedOptions: true,
|
||||
builderHint: null,
|
||||
pairs: false,
|
||||
editor: ENTRY_EDITORS.select,
|
||||
},
|
||||
media: {
|
||||
icon: 'i-lucide-image',
|
||||
label: () => t('Media'),
|
||||
description: () => t('One image from your media library'),
|
||||
cell: 'thumbnail',
|
||||
narrowColumn: true,
|
||||
alignEnd: false,
|
||||
supportsUnique: false,
|
||||
supportsLimits: false,
|
||||
supportsDefault: false,
|
||||
supportsChoices: false,
|
||||
showsUnsupportedOptions: false,
|
||||
builderHint: () =>
|
||||
t(
|
||||
'Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version.',
|
||||
),
|
||||
pairs: false,
|
||||
editor: ENTRY_EDITORS.media,
|
||||
},
|
||||
}
|
||||
|
||||
export function isFieldType(type: string): type is FieldType {
|
||||
|
||||
@@ -5,3 +5,8 @@ export interface ImageChoice {
|
||||
square?: boolean
|
||||
confirmLabel: string
|
||||
}
|
||||
|
||||
export interface FieldPick {
|
||||
optional: boolean
|
||||
currentId: number | null
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import type { ContentEntry } from '@/lib/contentTypes'
|
||||
import { formatDimensions, formatSize } from '@/lib/mediaFormat'
|
||||
|
||||
const MEDIA_ID_MAX = 4294967295
|
||||
|
||||
export interface MediaImage {
|
||||
id: number
|
||||
filename: string
|
||||
width: number | null
|
||||
height: number | null
|
||||
size: number
|
||||
thumbnail_url: string
|
||||
}
|
||||
|
||||
export interface MissingImage {
|
||||
id: number
|
||||
missing: true
|
||||
}
|
||||
|
||||
export type MediaSummary = MediaImage | MissingImage
|
||||
|
||||
export type MediaMap = Record<string, MediaSummary>
|
||||
|
||||
export function isMissingImage(image: MediaSummary): image is MissingImage {
|
||||
return 'missing' in image
|
||||
}
|
||||
|
||||
export function isMediaId(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= MEDIA_ID_MAX
|
||||
)
|
||||
}
|
||||
|
||||
export function mediaOf(
|
||||
entry: Pick<ContentEntry, 'media'> | null | undefined,
|
||||
key: string,
|
||||
): MediaSummary | null {
|
||||
return entry?.media?.[key] ?? null
|
||||
}
|
||||
|
||||
export function imageOfItem(item: MediaItem): MediaImage {
|
||||
return {
|
||||
id: item.id,
|
||||
filename: item.filename,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
size: item.size,
|
||||
thumbnail_url: item.url,
|
||||
}
|
||||
}
|
||||
|
||||
export function imageMeta(image: MediaImage) {
|
||||
return `${formatDimensions(image.width, image.height)} · ${formatSize(image.size)}`
|
||||
}
|
||||
|
||||
export function imageButtonName(image: MediaImage) {
|
||||
const dimensions =
|
||||
image.width === null || image.height === null
|
||||
? formatDimensions(null, null)
|
||||
: t('{0} by {1}', [String(image.width), String(image.height)])
|
||||
return t('Change image, {0}, {1}, {2}', [image.filename, dimensions, formatSize(image.size)])
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { t } from '@/i18n'
|
||||
import { formatNumber, t } from '@/i18n'
|
||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||
import type { BuilderField } from '@/lib/schemaBuilderModel'
|
||||
import { payloadField, type PayloadField } from '@/lib/schemaBuilderPayload'
|
||||
|
||||
export type ChangeKind = 'add' | 'change' | 'remove'
|
||||
export type ChangeFlag = 'mayNotFit' | 'removesData'
|
||||
export type ChangeFlag = 'mayNotFit' | 'removesData' | 'cannotApply'
|
||||
|
||||
export interface ChangeNote {
|
||||
text: string
|
||||
@@ -19,6 +19,7 @@ export interface FieldChange {
|
||||
label: string
|
||||
notes: ChangeNote[]
|
||||
flag: ChangeFlag | null
|
||||
mediaAdd?: boolean
|
||||
}
|
||||
|
||||
type Bound = number | string | null
|
||||
@@ -150,7 +151,35 @@ function changeNotes(before: PayloadField, after: PayloadField) {
|
||||
return notes
|
||||
}
|
||||
|
||||
function addNote(field: PayloadField) {
|
||||
function mediaAddNote(field: PayloadField, total: number) {
|
||||
const summary = `${fieldTypeSpec(field.type).label()}, ${field.required ? t('required') : t('optional')}.`
|
||||
if (field.required) {
|
||||
const cause =
|
||||
total === 1
|
||||
? t(
|
||||
"A media field can't have a default, so the existing entry would have no image. Make {0} optional to add it.",
|
||||
[field.label],
|
||||
)
|
||||
: t(
|
||||
"A media field can't have a default, so the {0} existing entries would have no image. Make {1} optional to add it.",
|
||||
[formatNumber(total), field.label],
|
||||
)
|
||||
return note(`${summary} ${cause}`, '', true)
|
||||
}
|
||||
if (total <= 0) {
|
||||
return note(summary)
|
||||
}
|
||||
const unaffected =
|
||||
total === 1
|
||||
? t('1 entry is unaffected.')
|
||||
: t('{0} entries are unaffected.', [formatNumber(total)])
|
||||
return note(`${summary} ${unaffected}`)
|
||||
}
|
||||
|
||||
function addNote(field: PayloadField, total: number) {
|
||||
if (!fieldTypeSpec(field.type).supportsDefault) {
|
||||
return mediaAddNote(field, total)
|
||||
}
|
||||
const parts = [
|
||||
fieldTypeSpec(field.type).label(),
|
||||
field.required ? t('required') : t('optional'),
|
||||
@@ -175,9 +204,19 @@ function flagOf(notes: readonly ChangeNote[]): ChangeFlag | null {
|
||||
return notes.some((entry) => entry.flagged) ? 'mayNotFit' : null
|
||||
}
|
||||
|
||||
function addFlag(
|
||||
field: PayloadField,
|
||||
notes: readonly ChangeNote[],
|
||||
total: number,
|
||||
): ChangeFlag | null {
|
||||
const refused = !fieldTypeSpec(field.type).supportsDefault && field.required && total > 0
|
||||
return refused ? 'cannotApply' : flagOf(notes)
|
||||
}
|
||||
|
||||
export function diffFields(
|
||||
baseline: readonly PayloadField[],
|
||||
fields: readonly BuilderField[],
|
||||
total = 0,
|
||||
): FieldChange[] {
|
||||
const before = new Map(baseline.map((field) => [field.key, field]))
|
||||
const additions: FieldChange[] = []
|
||||
@@ -186,14 +225,15 @@ export function diffFields(
|
||||
const after = payloadField(field)
|
||||
const previous = field.saved ? before.get(field.key) : undefined
|
||||
if (!field.saved || !previous) {
|
||||
const notes = [addNote(after)]
|
||||
const notes = [addNote(after, total)]
|
||||
additions.push({
|
||||
kind: 'add',
|
||||
uid: field.uid,
|
||||
key: after.key,
|
||||
label: after.label,
|
||||
notes,
|
||||
flag: flagOf(notes),
|
||||
flag: addFlag(after, notes, total),
|
||||
...(fieldTypeSpec(after.type).supportsDefault ? {} : { mediaAdd: true }),
|
||||
})
|
||||
} else if (fieldSignature(previous) !== fieldSignature(after)) {
|
||||
const notes = changeNotes(previous, after)
|
||||
@@ -247,6 +287,14 @@ export function flaggedChanges(changes: readonly FieldChange[]) {
|
||||
return changes.filter((change) => change.flag === 'mayNotFit')
|
||||
}
|
||||
|
||||
export function isMediaOnlyClean(changes: readonly FieldChange[]) {
|
||||
return changes.length > 0 && changes.every((change) => change.mediaAdd && change.flag === null)
|
||||
}
|
||||
|
||||
export function hasRefusedChange(changes: readonly FieldChange[]) {
|
||||
return changes.some((change) => change.flag === 'cannotApply')
|
||||
}
|
||||
|
||||
export function summaryLine(changes: readonly FieldChange[]) {
|
||||
const count = (kind: ChangeKind) => changes.filter((change) => change.kind === kind).length
|
||||
const added = count('add')
|
||||
|
||||
@@ -107,6 +107,10 @@ export function supportsUnique(type: FieldType) {
|
||||
return FIELD_TYPE_SPECS[type].supportsUnique
|
||||
}
|
||||
|
||||
export function supportsChoices(type: FieldType) {
|
||||
return FIELD_TYPE_SPECS[type].supportsChoices
|
||||
}
|
||||
|
||||
export function supportsLimits(type: FieldType) {
|
||||
return FIELD_TYPE_SPECS[type].supportsLimits
|
||||
}
|
||||
@@ -132,8 +136,12 @@ export function isUnique(field: BuilderField) {
|
||||
return field.unique && supportsUnique(field.type)
|
||||
}
|
||||
|
||||
export function supportsDefault(type: FieldType) {
|
||||
return FIELD_TYPE_SPECS[type].supportsDefault
|
||||
}
|
||||
|
||||
export function effectiveDefault(field: BuilderField) {
|
||||
if (isUnique(field) || field.defaultValue === '') {
|
||||
if (!supportsDefault(field.type) || isUnique(field) || field.defaultValue === '') {
|
||||
return ''
|
||||
}
|
||||
if (field.type === 'select') {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
effectiveDefault,
|
||||
isUnique,
|
||||
supportsChoices,
|
||||
supportsLimits,
|
||||
trimmedOptions,
|
||||
type BuilderField,
|
||||
@@ -59,7 +60,7 @@ export function payloadField(field: BuilderField): PayloadField {
|
||||
default: defaultFor(field),
|
||||
min: limit(field, field.min),
|
||||
max: limit(field, field.max),
|
||||
options: field.type === 'select' ? trimmedOptions(field) : [],
|
||||
options: supportsChoices(field.type) ? trimmedOptions(field) : [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ export function sampleValue(field: BuilderField): string | number | boolean | nu
|
||||
return field.defaultOn
|
||||
case 'date':
|
||||
return given === '' ? SAMPLE_DATE : given
|
||||
case 'media':
|
||||
return null
|
||||
case 'select': {
|
||||
const choices = trimmedOptions(field).filter((option) => option !== '')
|
||||
return given !== '' ? given : (choices[0] ?? null)
|
||||
|
||||
Reference in new issue
Block a user