Files
RenewCMS/web/src/lib/__tests__/mediaField.spec.ts
T

541 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 and before relation', () => {
expect(FIELD_TYPES).toHaveLength(8)
expect(FIELD_TYPES[6]).toBe('media')
expect(FIELD_TYPES[7]).toBe('relation')
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' && type !== 'relation'))(
'%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: [],
relation: null,
})
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.',
})
})
})