feat(SPA): media field type

This commit is contained in:
Florian Sylvain committed 2026-10-21 15:17:15 +02:00
1 parent a2d596f4ca
commit 3e7fc9a4a5
57 files changed
+6109 -75

No files matched your search

+1
View File
@@ -9,6 +9,7 @@ const CONTROLS: Record<FieldType, string> = {
boolean: 'switch',
date: 'date',
select: 'select',
media: 'media',
}
function editor(type: FieldType) {
+538
View File
@@ -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.',
})
})
})
+3
View File
@@ -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
+25 -3
View File
@@ -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 }
}
+30 -2
View File
@@ -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,
},
}
+23 -3
View File
@@ -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)
}
+11
View File
@@ -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(
+64 -2
View File
@@ -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
View File
@@ -5,3 +5,8 @@ export interface ImageChoice {
square?: boolean
confirmLabel: string
}
export interface FieldPick {
optional: boolean
currentId: number | null
}
+64
View File
@@ -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)])
}
+53 -5
View File
@@ -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')
+9 -1
View File
@@ -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') {
+2 -1
View File
@@ -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) : [],
}
}
+2
View File
@@ -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)