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
@@ -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.',
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user