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

+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.',
})
})
})