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

@@ -0,0 +1,290 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import type { MediaSummary } from '@/lib/mediaField'
import {
calls,
eventEntries,
installSelectPolyfills,
listPage,
mountList,
text,
unmountAll,
} from './contentTestKit'
import { drawsIcon } from './mediaFieldKit'
import { eventsWithThumbnail } from './schemaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeAll(installSelectPolyfills)
afterEach(async () => {
void router
vi.unstubAllGlobals()
await unmountAll()
})
const KEY = 'renewcms-content-columns-events'
const COLUMNS = ['title', 'thumbnail', 'starts_on', 'category']
function image(id: number, filename: string): MediaSummary {
return {
id,
filename,
width: 1600,
height: 900,
size: 245760,
thumbnail_url: `/media/${filename}`,
}
}
const THUMBNAILS: Record<number, [number | null, MediaSummary | null]> = {
42: [7, image(7, 'lime-plaster-workshop.jpg')],
41: [8, image(8, 'bathroom-tiles.png')],
40: [null, null],
39: [9, { id: 9, missing: true }],
38: [10, image(10, 'timber-joinery.jpg')],
37: [null, null],
36: [11, image(11, 'floorboards.jpg')],
}
const items = eventEntries.map((entry) => {
const [thumbnail, summary] = THUMBNAILS[entry.id]!
return {
...entry,
data: { ...entry.data, thumbnail },
...(summary ? { media: { thumbnail: summary } } : {}),
}
})
function thumbnailSecond() {
const type = eventsWithThumbnail()
const [title, ...rest] = type.fields
const thumbnail = rest.pop()!
return { ...type, fields: [title!, thumbnail, ...rest] }
}
function thumbnailFirst() {
const type = eventsWithThumbnail()
const thumbnail = type.fields[type.fields.length - 1]!
return { ...type, fields: [thumbnail, ...type.fields.slice(0, -1)] }
}
function mountWithThumbnail(
width?: number,
columns: string[] | null = COLUMNS,
type: () => ReturnType<typeof eventsWithThumbnail> = thumbnailSecond,
) {
return mountList('/content/events', {
width,
before: () => {
if (columns) {
localStorage.setItem(KEY, JSON.stringify(columns))
}
},
type,
content: () => [type()],
list: (_key, query) => listPage(items, 7, query),
})
}
const headers = () => [...document.body.querySelectorAll('thead th')]
const rowsOf = () => [...document.body.querySelectorAll('tbody tr')]
const thumbCell = (row: number) => rowsOf()[row]!.querySelectorAll('td')[2]!
describe('The Thumbnail column of the table', () => {
it('has a Thumbnail header between Title and Starts on, 80 px wide, with no sort', async () => {
await mountWithThumbnail()
expect(headers().map(text)).toEqual([
'ID',
'Title',
'Thumbnail',
'Starts on',
'Category',
'Created',
'Updated',
'Updated by',
])
const header = headers()[2]!
expect(header.querySelector('svg')).toBeNull()
expect(header.hasAttribute('aria-sort')).toBe(false)
expect(header.className).toContain('w-23')
expect(header.querySelector('span')!.className).not.toContain('justify-end')
})
it('follows the schema order of the type, so Thumbnail after Description comes last', async () => {
await mountWithThumbnail(undefined, COLUMNS, eventsWithThumbnail)
expect(headers().map(text).slice(0, 5)).toEqual([
'ID',
'Title',
'Starts on',
'Category',
'Thumbnail',
])
})
it('draws a 40 px thumbnail from the item media with the filename as alt', async () => {
await mountWithThumbnail()
const img = thumbCell(0).querySelector('img')!
expect(img.getAttribute('src')).toBe('/media/lime-plaster-workshop.jpg')
expect(img.getAttribute('alt')).toBe('lime-plaster-workshop.jpg')
expect(img.parentElement!.className).toContain('size-10')
expect(rowsOf()[0]!.querySelector('td')!.className).toContain('h-12')
})
it('shows a muted dash for no image', async () => {
await mountWithThumbnail()
const cell = thumbCell(2)
expect(text(cell)).toBe('–')
expect(cell.querySelector('span')!.className).toContain('text-muted')
expect(cell.querySelector('img')).toBeNull()
})
it('shows a dashed 40 px square with the unlink icon and the missing name for a gone image', async () => {
await mountWithThumbnail()
const placeholder = thumbCell(3).querySelector('[role="img"]')!
expect(placeholder.getAttribute('aria-label')).toBe('Image no longer in the library')
expect(placeholder.className).toContain('size-10')
expect(placeholder.className).toContain('border-dashed')
expect(drawsIcon(placeholder, 'unlink')).toBe(true)
expect(thumbCell(3).querySelector('img')).toBeNull()
})
it('makes no request for the thumbnails', async () => {
await mountWithThumbnail()
expect(calls.filter((url) => url.startsWith('/v1/media'))).toEqual([])
})
it('keeps a media first column as the link to the editor', async () => {
await mountWithThumbnail(undefined, ['thumbnail', 'title'], thumbnailFirst)
const link = rowsOf()[0]!.querySelector<HTMLAnchorElement>('[data-testid="entry-link"]')!
expect(link.getAttribute('href')).toBe('/content/events/42')
expect(link.querySelector('img')).not.toBeNull()
})
})
describe('The tooltip of the missing placeholder', () => {
it('reads the missing copy on hover on desktop', async () => {
await mountWithThumbnail()
const placeholder = thumbCell(3).querySelector<HTMLElement>('[role="img"]')!
placeholder.focus()
placeholder.dispatchEvent(new MouseEvent('pointermove', { bubbles: true }))
await flushPromises()
expect(placeholder.tabIndex).toBe(0)
await vi.waitFor(() =>
expect([...document.body.querySelectorAll('[role="tooltip"]')].map(text)).toContain(
'Image no longer in the library',
),
)
})
})
describe('The cards on mobile', () => {
const cards = () => [...document.body.querySelectorAll('[data-testid="entry-card"]')]
it('leads each card with a 32 px thumbnail and keeps the minimum height', async () => {
await mountWithThumbnail(MOBILE_WIDTH)
const first = cards()[0]!
const lead = first.firstElementChild!
expect(lead.querySelector('img')!.parentElement!.className).toContain('size-8')
expect(first.className).toContain('min-h-17')
expect(text(first)).toContain('Lime plaster workshop')
expect(text(first)).toContain('21 Nov 2026')
})
it('draws a dash for none and a dashed 32 px square for a missing image', async () => {
await mountWithThumbnail(MOBILE_WIDTH)
expect(text(cards()[2]!.firstElementChild)).toBe('–')
const placeholder = cards()[3]!.querySelector('[role="img"]')!
expect(placeholder.className).toContain('size-8')
expect(placeholder.className).toContain('border-dashed')
expect(placeholder.getAttribute('aria-label')).toBe('Image no longer in the library')
expect(placeholder.getAttribute('tabindex')).toBeNull()
})
it('leaves the card unchanged when no media column is chosen', async () => {
await mountWithThumbnail(MOBILE_WIDTH, ['title', 'starts_on', 'category'])
const first = cards()[0]!
expect(first.querySelector('img')).toBeNull()
expect(first.querySelector('[aria-label="Image no longer in the library"]')).toBeNull()
expect([...first.children].filter((child) => child.tagName === 'SPAN')).toHaveLength(2)
})
it('reads Entry N as the title when the first column is media and never the id', async () => {
await mountWithThumbnail(MOBILE_WIDTH, ['thumbnail', 'title', 'starts_on'], thumbnailFirst)
const first = cards()[0]!
expect(text(first.querySelector('.font-medium'))).toBe('Entry 42')
expect(text(first)).not.toContain(' 7 ')
})
})
describe('The Columns menu', () => {
function openColumns() {
const trigger = [...document.body.querySelectorAll('button')].find(
(candidate) =>
text(candidate) === 'Columns' || candidate.getAttribute('aria-label') === 'Columns',
)!
trigger.click()
return flushPromises()
}
it('lists Thumbnail with the image icon, its key and a checkbox, with 4 of 6', async () => {
await mountWithThumbnail(undefined, COLUMNS, eventsWithThumbnail)
await openColumns()
const row = [...document.body.querySelectorAll('[data-testid="column-row"]')].find(
(candidate) => text(candidate).includes('Thumbnail'),
)!
expect(text(row)).toBe('Thumbnail thumbnail')
expect(drawsIcon(row, 'image')).toBe(true)
expect(row.querySelector('[role="checkbox"]')!.getAttribute('aria-checked')).toBe('true')
expect(text(document.body.querySelector('[data-testid="column-picker"]'))).toContain(
'4 of 6',
)
})
it('reads the mobile summary and keeps the cap and the stored choice', async () => {
await mountWithThumbnail(MOBILE_WIDTH, COLUMNS, eventsWithThumbnail)
await openColumns()
expect(text(document.body.querySelector('[role="dialog"]'))).toContain(
'Choose up to 6 fields to show. 4 selected.',
)
const row = [...document.body.querySelectorAll('[data-testid="column-row"]')].find(
(candidate) => text(candidate).includes('Thumbnail'),
)!
row.querySelector<HTMLElement>('[role="checkbox"]')!.click()
await flushPromises()
expect(JSON.parse(localStorage.getItem(KEY)!)).toEqual(['title', 'starts_on', 'category'])
})
it('offers Thumbnail unchecked by default beyond the first four fields', async () => {
await mountWithThumbnail(undefined, null, eventsWithThumbnail)
await openColumns()
const row = [...document.body.querySelectorAll('[data-testid="column-row"]')].find(
(candidate) => text(candidate).includes('Thumbnail'),
)!
expect(row.querySelector('[role="checkbox"]')!.getAttribute('aria-checked')).toBe('false')
})
})
@@ -0,0 +1,590 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import type { MediaSummary } from '@/lib/mediaField'
import {
answerWrite,
button,
calls,
chip,
entry42,
entryWrites,
failWithBody,
fieldWrapper,
hasText,
main,
mountEditor,
pending,
save,
setField,
text,
typeInto,
unmountAll,
} from './entryEditorKit'
import {
answerMediaWrite,
entryWithThumbnail,
focusedName,
libraryBody,
mediaButton,
mediaControl,
mediaState,
mediaText,
mountMedia,
pickFromLibrary,
summaryOf,
} from './entryMediaKit'
import { drawsIcon } from './mediaFieldKit'
import { customTypes, eventsWithThumbnail, thumbnailField, apiField } from './schemaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountAll)
const MISSING: MediaSummary = { id: 7, missing: true }
const REQUIRED_TEXT = 'Pick an image for Thumbnail.'
const fieldKeys = () =>
[...document.body.querySelectorAll<HTMLElement>('[data-entry-field]')].map(
(element) => element.dataset.entryField,
)
const sentData = (index = 0) => entryWrites[index]!.body.data as Record<string, unknown>
const liveText = () => text(fieldWrapper('thumbnail').querySelector('[data-testid="media-live"]'))
const errorLine = () => text(fieldWrapper('thumbnail').querySelector('[role="alert"]'))
async function editTitle() {
await typeInto('title', 'Lime plaster workshop, two days')
}
describe('Where the control sits', () => {
it('is the eighth field, after Description, on its own full width row', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
expect(fieldKeys()).toEqual([
'title',
'starts_on',
'ends_on',
'online',
'capacity',
'category',
'description',
'thumbnail',
])
const row = fieldWrapper('thumbnail').parentElement!
expect(row.children).toHaveLength(1)
expect(row.className).not.toContain('md:grid-cols-2')
expect(row.nextElementSibling!.getAttribute('data-testid')).toBe('entry-meta')
})
it('never pairs with a boolean, number or date field before it', async () => {
const fields = [
apiField('Online', 'online', 'boolean'),
thumbnailField(),
apiField('Title', 'title', 'text', { required: true }),
]
const type = { ...customTypes[0]!, fields }
await mountEditor('/content/events/new', { type: () => type, content: () => [type] })
const row = fieldWrapper('thumbnail').parentElement!
expect(row.children).toHaveLength(1)
expect(fieldWrapper('online').parentElement!.children).toHaveLength(1)
})
it('shows the new entry control empty, optional and without a Default tag', async () => {
await mountMedia('/content/events/new')
expect(mediaState()).toBe('empty')
expect(mediaText()).toContain('Thumbnail')
expect(mediaText()).not.toContain('Default')
expect(mediaText()).toContain('One image from your library.')
})
it('shows the footer lines after the control on mobile too', async () => {
await mountMedia('/content/events/42', { thumbnail: 7, width: MOBILE_WIDTH })
expect(
fieldWrapper('thumbnail').parentElement!.nextElementSibling!.getAttribute(
'data-testid',
),
).toBe('entry-meta')
})
})
describe('What the control shows and sends', () => {
it.each([
['null', null, 'none', async () => {}, 'empty', null],
['7', 7, undefined, async () => {}, 'filled', 7],
['missing 7', 7, MISSING, async () => {}, 'missing', 7],
[
'7 then 8',
7,
undefined,
async () => {
await pickFromLibrary('bathroom-tiles.png')
},
'filled',
8,
],
[
'7 then Remove',
7,
undefined,
async () => {
mediaButton('Remove image from Thumbnail')!.click()
await flushPromises()
},
'empty',
null,
],
[
'missing 7 then Clear',
7,
MISSING,
async () => {
mediaButton('Clear Thumbnail')!.click()
await flushPromises()
},
'empty',
null,
],
[
'null then 8',
null,
'none',
async () => {
await pickFromLibrary('bathroom-tiles.png', 'Pick an image for Thumbnail')
},
'filled',
8,
],
] as const)(
'loads %s and shows %s after the save',
async (_name, loaded, media, action, shown, sent) => {
await mountMedia('/content/events/42', {
thumbnail: loaded,
media,
writeEntry: (write) => {
const answered = answerWrite(write)
const id = (write.body.data as Record<string, unknown>).thumbnail
return typeof id !== 'number'
? answered
: {
...answered,
media: {
thumbnail:
id === 7 && media === MISSING ? MISSING : summaryOf(id),
},
}
},
})
await action()
await editTitle()
await save()
expect(entryWrites).toHaveLength(1)
expect(sentData().thumbnail).toBe(sent)
expect(Object.keys(sentData())).toContain('thumbnail')
expect(mediaState()).toBe(shown)
},
)
it('keeps a missing optional image unchanged and saves', async () => {
await mountMedia('/content/events/42', { thumbnail: 7, media: MISSING })
await editTitle()
await save()
expect(sentData().thumbnail).toBe(7)
expect(hasText('Entry saved')).toBe(true)
})
})
describe('The data of the control', () => {
it('shows the stored image of the entry with its size line', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
expect(mediaText()).toContain('lime-plaster-workshop.jpg')
expect(mediaText()).toContain('1600 × 900 · 240 KB')
})
it('shows the picked library item before any save with no request for it', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await pickFromLibrary('bathroom-tiles.png')
expect(mediaText()).toContain('bathroom-tiles.png')
expect(mediaText()).toContain('800 × 600 · 50 KB')
expect(
mediaControl()
.querySelector('[data-testid="media-thumbnail"] img')!
.getAttribute('src'),
).toBe('/media/bathroom-tiles.png')
expect(calls.filter((url) => /^\/v1\/media\/\d+/.test(url))).toEqual([])
expect(entryWrites).toHaveLength(0)
expect(liveText()).toBe('Image selected: bathroom-tiles.png')
})
it('rebuilds the control from the answered media after the PUT', async () => {
await mountMedia('/content/events/42', {
thumbnail: 7,
writeEntry: (write) => ({
...answerWrite(write),
media: {
thumbnail: { ...summaryOf(8), filename: 'bathroom-tiles-final.png' },
},
}),
})
await pickFromLibrary('bathroom-tiles.png')
await save()
expect(mediaText()).toContain('bathroom-tiles-final.png')
expect(mediaText()).toContain('800 × 600 · 50 KB')
})
})
describe('Dirty tracking of a media value', () => {
it('reads Saved and disables Save when 8 is picked then 7 again', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await pickFromLibrary('bathroom-tiles.png')
expect(chip()!.textContent).toContain('Unsaved changes')
await pickFromLibrary('lime-plaster-workshop.jpg')
expect(chip()!.textContent).toContain('Saved')
expect(chip()!.textContent).not.toContain('Unsaved')
expect((button('Save') as HTMLButtonElement).disabled).toBe(true)
})
it('stays Saved when the stored image is picked again from the library', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await pickFromLibrary('lime-plaster-workshop.jpg')
expect(chip()!.textContent).not.toContain('Unsaved')
expect((button('Save') as HTMLButtonElement).disabled).toBe(true)
expect(liveText()).toBe('')
})
it('prompts the leave guard after picking another image', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await pickFromLibrary('bathroom-tiles.png')
document.body.querySelector<HTMLAnchorElement>('nav[aria-label="breadcrumb"] a')!.click()
await vi.waitFor(() => expect(hasText('Leave without saving?')).toBe(true))
})
it('does not prompt after picking and restoring the stored image', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await pickFromLibrary('bathroom-tiles.png')
await pickFromLibrary('lime-plaster-workshop.jpg')
document.body.querySelector<HTMLAnchorElement>('nav[aria-label="breadcrumb"] a')!.click()
await flushPromises()
expect(hasText('Leave without saving?')).toBe(false)
})
})
describe('Client validation', () => {
it('refuses an empty required field with no request, the error, Not saved and the focus', async () => {
const wrapper = await mountMedia('/content/events/new', { required: true })
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
await save()
expect(entryWrites).toHaveLength(0)
expect(errorLine()).toBe(REQUIRED_TEXT)
expect(mediaControl().className).toContain('border-error')
expect(chip()!.textContent).toContain('Not saved')
expect(focusedName()).toBe('Pick an image for Thumbnail')
expect(document.activeElement!.getAttribute('aria-invalid')).toBe('true')
})
it('refuses a required field whose image is gone, focusing Choose another image', async () => {
await mountMedia('/content/events/42', { thumbnail: 7, media: MISSING, required: true })
await editTitle()
await save()
expect(entryWrites).toHaveLength(0)
expect(errorLine()).toBe(REQUIRED_TEXT)
expect(mediaText()).toContain('Thumbnail is required. Choose another image to save.')
expect(drawsIcon(fieldWrapper('thumbnail'), 'circle-alert')).toBe(true)
expect(chip()!.textContent).toContain('Not saved')
expect(focusedName()).toBe('Choose another image')
})
it('clears the error and the border when an image is picked, validating nothing else', async () => {
await mountMedia('/content/events/new', { required: true })
await typeInto('title', 'Open day')
await save()
expect(errorLine()).toBe(REQUIRED_TEXT)
expect(hasText('Pick a date for Starts on.')).toBe(true)
await pickFromLibrary('bathroom-tiles.png', 'Pick an image for Thumbnail')
expect(errorLine()).toBe('')
expect(mediaControl().className).not.toContain('border-error')
expect(chip()!.textContent).toContain('Unsaved changes')
expect(hasText('Pick a date for Starts on.')).toBe(true)
})
it('focuses only the first field in schema order that has an error', async () => {
await mountMedia('/content/events/new', { required: true })
await save()
expect(document.activeElement).toBe(fieldWrapper('title').querySelector('input'))
})
it('focuses the thumbnail control when it is the first error', async () => {
const wrapper = await mountMedia('/content/events/new', { required: true })
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
await save()
expect(fieldWrapper('thumbnail').contains(document.activeElement)).toBe(true)
})
})
describe('Saving', () => {
it('dims the form to 70 %, the thumbnail to 60 % and disables the buttons', async () => {
const answer = pending()
await mountMedia('/content/events/42', {
thumbnail: 7,
writeEntry: () => answer.promise,
})
await editTitle()
await save()
const form = document.body.querySelector('[data-testid="entry-form"]')!
expect(form.className).toContain('opacity-70')
expect(
fieldWrapper('thumbnail')
.querySelector('[data-testid="media-thumbnail"]')!
.className.split(' '),
).toContain('opacity-60')
expect(
[...fieldWrapper('thumbnail').querySelectorAll<HTMLButtonElement>('button')].every(
(candidate) => candidate.disabled,
),
).toBe(true)
expect(hasText('Saving…')).toBe(true)
answer.resolve(undefined)
await flushPromises()
})
})
describe('Server answers for a media field', () => {
it('shows 422 required under the control with the error border and the focus on the first button', async () => {
await mountMedia('/content/events/42', {
thumbnail: 7,
writeEntry: failWithBody(422, 'invalid_entry: thumbnail: required'),
})
await editTitle()
await save()
expect(errorLine()).toBe(REQUIRED_TEXT)
expect(mediaControl().className).toContain('border-error')
expect(chip()!.textContent).toContain('Not saved')
expect(fieldWrapper('thumbnail').contains(document.activeElement)).toBe(true)
})
it('shows 422 type under the control and focuses Change image', async () => {
await mountMedia('/content/events/42', {
thumbnail: 7,
writeEntry: failWithBody(422, 'invalid_entry: thumbnail: type'),
})
await pickFromLibrary('bathroom-tiles.png')
await save()
expect(errorLine()).toBe('Thumbnail must be an image from the library.')
expect(chip()!.textContent).toContain('Not saved')
expect(focusedName()).toBe('Change image')
})
it('shows the changed type notice for an unknown media key and locks the control', async () => {
await mountMedia('/content/events/42', {
thumbnail: 7,
writeEntry: failWithBody(422, 'invalid_entry: thumbnail: unknown_field'),
})
await editTitle()
await save()
expect(hasText('The fields of Events changed')).toBe(true)
expect(
hasText(
"The Thumbnail 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.",
),
).toBe(true)
expect(button('Reload form')).toBeInstanceOf(HTMLButtonElement)
expect(
[...fieldWrapper('thumbnail').querySelectorAll<HTMLButtonElement>('button')].every(
(candidate) => candidate.disabled,
),
).toBe(true)
expect(
fieldWrapper('thumbnail').querySelector('[data-testid="media-thumbnail"]')!.className,
).toContain('opacity-60')
})
it('keeps the old copy for the other keys', async () => {
await mountMedia('/content/events/42', {
thumbnail: 7,
writeEntry: failWithBody(422, 'invalid_entry: colour: unknown_field'),
})
await editTitle()
await save()
expect(hasText('A field was removed and a new required field, Colour, was added')).toBe(
true,
)
expect(hasText('The Thumbnail field was changed')).toBe(false)
})
it('rebuilds the form from a refetched type and entry on Reload form', async () => {
let reads = 0
await mountMedia('/content/events/42', {
thumbnail: 7,
writeEntry: failWithBody(422, 'invalid_entry: thumbnail: unknown_field'),
entry: () => {
reads += 1
return reads === 1
? entryWithThumbnail(7)
: { ...entryWithThumbnail(8), media: { thumbnail: summaryOf(8) } }
},
})
await editTitle()
await save()
button('Reload form')!.click()
await flushPromises()
await flushPromises()
expect(reads).toBe(2)
expect(mediaText()).toContain('bathroom-tiles.png')
expect(hasText('The fields of Events changed')).toBe(false)
expect(
[...fieldWrapper('thumbnail').querySelectorAll<HTMLButtonElement>('button')].some(
(candidate) => candidate.disabled,
),
).toBe(false)
})
})
describe('The create payload', () => {
it('sends thumbnail null among the other keys when the field is left empty', async () => {
const wrapper = await mountMedia('/content/events/new')
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
await save()
expect(entryWrites).toHaveLength(1)
expect(entryWrites[0]).toMatchObject({
method: 'POST',
url: '/v1/content-type/events/entry',
})
expect(Object.keys(sentData())).toEqual([
'title',
'starts_on',
'ends_on',
'online',
'capacity',
'category',
'description',
'thumbnail',
])
expect(sentData().thumbnail).toBeNull()
})
it('sends the picked id as a JSON number', async () => {
const wrapper = await mountMedia('/content/events/new')
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
await pickFromLibrary('bathroom-tiles.png', 'Pick an image for Thumbnail')
await save()
expect(sentData().thumbnail).toBe(8)
expect(JSON.stringify(entryWrites[0]!.body)).toContain('"thumbnail":8')
})
})
describe('The title of an entry whose first field is media', () => {
const type = {
...customTypes[0]!,
fields: [
thumbnailField(),
apiField('Title', 'title', 'text', { required: true, options: [] }),
],
}
const entry = {
...entry42,
data: { thumbnail: 7, title: 'Lime plaster workshop' },
media: { thumbnail: summaryOf(7) },
}
const options = {
type: () => type,
content: () => [type],
entry: () => entry,
library: () => libraryBody(),
}
it('reads Entry 42 as the page title and never the numeric id', async () => {
await mountEditor('/content/events/42', options)
expect(text(main().querySelector('h1'))).toBe('Entry 42')
expect(text(main().querySelector('h1'))).not.toContain('7')
})
it('reads Entry 42 in the saved toast', async () => {
await mountEditor('/content/events/42', {
...options,
writeEntry: (write) => ({
...answerMediaWrite(write),
data: { ...entry.data, ...(write.body.data as object) },
}),
})
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(hasText('Entry saved')).toBe(true)
expect(hasText('"Entry 42" is up to date.')).toBe(true)
})
it('reads Entry 42 in the leave prompt', async () => {
await mountEditor('/content/events/42', options)
await typeInto('title', 'Changed')
document.body.querySelector<HTMLAnchorElement>('nav[aria-label="breadcrumb"] a')!.click()
await vi.waitFor(() =>
expect(hasText('Your changes to "Entry 42" haven\'t been saved.')).toBe(true),
)
})
})
describe('Accessibility of the control in the form', () => {
it('draws the control with the image icon and the group label', async () => {
await mountMedia('/content/events/new')
expect(mediaControl().getAttribute('role')).toBe('group')
expect(drawsIcon(mediaControl(), 'image')).toBe(true)
expect(eventsWithThumbnail().fields).toHaveLength(8)
})
})
@@ -0,0 +1,159 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
button,
calls,
chip,
failWith,
fieldWrapper,
hasText,
text,
unmountAll,
} from './entryEditorKit'
import {
focusedName,
libraryBody,
mediaButton,
mediaState,
mediaText,
mountMedia,
pickFromLibrary,
} from './entryMediaKit'
import { fieldLibrary } from './mediaFieldKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountAll)
const dialog = () => document.body.querySelector<HTMLElement>('[role="dialog"]')
const tiles = () => [...document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]')]
async function open(opener = 'Change image') {
mediaButton(opener)!.click()
await flushPromises()
await flushPromises()
}
describe('The picker opened from the entry editor', () => {
it('loads only the library page and never the variants', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await open()
expect(calls.filter((url) => url.startsWith('/v1/media/'))).toEqual([
'/v1/media/?page=1&size=25',
])
expect(calls.some((url) => url.includes('image-variants'))).toBe(false)
expect(text(dialog())).toContain('Choose an image')
expect(text(dialog())).not.toContain('Alt text')
})
it('marks the stored image as Current', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await open()
const current = tiles().filter((tile) =>
tile.getAttribute('aria-label')!.endsWith(', Current'),
)
expect(current.map((tile) => tile.dataset.imageId)).toEqual(['7'])
})
it('is 640 px wide on desktop and a bottom sheet on mobile', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await open()
expect(dialog()!.className).toContain('sm:max-w-160')
unmountAll()
await mountMedia('/content/events/42', { thumbnail: 7, width: MOBILE_WIDTH })
await open()
expect(dialog()!.className).toContain('max-sm:!bottom-0')
})
it('does not open while the form is saving', async () => {
let release = () => {}
await mountMedia('/content/events/42', {
thumbnail: 7,
writeEntry: () => new Promise((resolve) => (release = () => resolve(undefined))),
})
const title = fieldWrapper('title').querySelector('input')!
title.value = 'Changed'
title.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
button('Save')!.click()
await flushPromises()
mediaButton('Change image')!.click()
await flushPromises()
expect(dialog()).toBeNull()
expect(hasText('Saving…')).toBe(true)
release()
await flushPromises()
})
it('shows the empty library with the field copy and focuses nothing else', async () => {
await mountMedia('/content/events/new', {
library: () => ({ ...libraryBody([]), total: 0 }),
})
await open('Pick an image for Thumbnail')
const library = [...document.body.querySelectorAll<HTMLElement>('[role="tab"]')].find(
(tab) => text(tab) === 'Library',
)!
library.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
await flushPromises()
expect(text(dialog())).toContain(
'Images you upload appear here, ready to use in any article or entry.',
)
})
it('shows the error alert when the library fails and keeps the entry untouched', async () => {
await mountMedia('/content/events/42', { thumbnail: 7, library: failWith(500) })
await open()
expect(text(dialog())).toContain("Couldn't load your library")
expect(text(dialog())).toContain('Try again')
expect(chip()!.textContent).toContain('Saved')
})
})
describe('Focus and announcements in the editor', () => {
it('lands on Change image after Use this image', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
await pickFromLibrary('bathroom-tiles.png')
expect(focusedName()).toBe('Change image')
expect(mediaState()).toBe('filled')
expect(mediaText()).toContain('bathroom-tiles.png')
})
it('lands on Pick an image after removing the image', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
mediaButton('Remove image from Thumbnail')!.click()
await flushPromises()
await flushPromises()
expect(focusedName()).toBe('Pick an image for Thumbnail')
expect(chip()!.textContent).toContain('Unsaved changes')
})
it('returns to the opener with Cancel', async () => {
await mountMedia('/content/events/42', { thumbnail: 7 })
const opener = mediaButton('Change image')!
opener.focus()
await open()
;[...dialog()!.querySelectorAll('button')].find((b) => text(b) === 'Cancel')!.click()
await vi.waitFor(() => expect(document.activeElement).toBe(opener))
expect(fieldLibrary()).toHaveLength(12)
})
})
@@ -388,7 +388,7 @@ describe('Saved fields are locked', () => {
expect(text(inspector())).toContain("Used in the API, so it can't change.")
expect(inspector()!.querySelector('button[aria-label="Edit key"]')).toBeNull()
expect(text(inspector())).toContain("Can't change after saving")
expect(all('[role="radio"]')).toHaveLength(6)
expect(all('[role="radio"]')).toHaveLength(7)
expect(tile('Date').getAttribute('data-state')).toBe('checked')
const others = all<HTMLButtonElement>('[role="radio"]').filter(
(radio) => radio !== tile('Date'),
@@ -421,7 +421,7 @@ describe('Saved fields are locked', () => {
})
describe('Fields added since the last save', () => {
it('selects a new row with New, an Auto key, six selectable tiles and no lock', async () => {
it('selects a new row with New, an Auto key, seven selectable tiles and no lock', async () => {
await mountAt('/schema/events')
await click(button('Add field'))
@@ -209,10 +209,11 @@ const TILES = [
['Yes or no', 'An on or off switch', 'toggle-left'],
['Date', 'A calendar day, without a time', 'calendar'],
['Choice', 'One option from a list you define', 'list'],
['Media', 'One image from your media library', 'image'],
] as const
describe('Type tiles', () => {
it('shows six radio tiles with an icon, a name and a description', async () => {
it('shows seven radio tiles with an icon, a name and a description', async () => {
await mountAt('/schema/new')
expect(el('[role="radiogroup"]')).not.toBeNull()
@@ -0,0 +1,404 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
button,
eventsWithThumbnail,
failWithBody,
mountAt,
posts,
puts,
text,
thumbnailMount,
unmountAll,
updatedType,
} from './schemaTestKit'
import {
addField,
all,
badgesOf,
chip,
click,
el,
fieldKeyInput,
inspector,
inspectorTitle,
labelInput,
nameInput,
pickTile,
rowOf,
saveButton,
selectRow,
tile,
toggle,
typeInto,
} from './builderKit'
import { drawsIcon } from './mediaFieldKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
beforeEach(() => {
Element.prototype.scrollIntoView = () => undefined
})
afterEach(unmountAll)
const TILES = [
['Text', 'type', 'Short text, like a title or a name'],
['Rich text', 'pilcrow', 'Long text with formatting, written in Markdown'],
['Number', 'hash', 'Whole or decimal numbers'],
['Yes or no', 'toggle-left', 'An on or off switch'],
['Date', 'calendar', 'A calendar day, without a time'],
['Choice', 'list', 'One option from a list you define'],
['Media', 'image', 'One image from your media library'],
]
const HINT =
'Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version.'
const grid = () => all('[role="radio"]')[0]!.closest<HTMLElement>('[class*="grid-cols"]')!
const savedThumbnail = (rest: object = {}) => thumbnailMount(rest)
async function addMediaField() {
await addField('Thumbnail')
await pickTile('Media')
}
describe('Seven type tiles', () => {
it('lists the seven tiles in order, each with its name and description', async () => {
await mountAt('/schema/events')
await click(button('Add field'))
const labels = all('[role="radio"]').map((radio) => radio.closest('label')!)
expect(labels).toHaveLength(7)
labels.forEach((label, index) => {
const [name, , description] = TILES[index]!
expect(text(label)).toBe(`${name} ${description}`)
})
})
it('draws the icon of each tile', async () => {
await mountAt('/schema/events')
await click(button('Add field'))
const labels = all('[role="radio"]').map((radio) => radio.closest('label')!)
labels.forEach((label, index) => {
expect(drawsIcon(label, TILES[index]![1])).toBe(true)
})
})
it('has 3 columns in the desktop inspector', async () => {
await mountAt('/schema/events')
await click(button('Add field'))
expect(grid().className).toContain('grid-cols-3')
})
it('has 2 columns in the mobile sheet', async () => {
await mountAt('/schema/events', { width: MOBILE_WIDTH })
await click(button('Add field'))
expect(grid().className).toContain('grid-cols-2')
expect(grid().className).not.toContain('grid-cols-3')
})
})
describe('Adding a media field', () => {
it('shows the New badge, an editable Auto key with its help and all seven tiles enabled', async () => {
await mountAt('/schema/events')
await addMediaField()
expect(badgesOf('Thumbnail')).toContain('New')
expect(fieldKeyInput()!.value).toBe('thumbnail')
expect(inspector()!.querySelector('button[aria-label="Edit key"]')).not.toBeNull()
expect(text(inspector())).toContain(
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
)
expect(all('[role="radio"]')).toHaveLength(7)
expect(all<HTMLButtonElement>('[role="radio"]').every((radio) => !radio.disabled)).toBe(
true,
)
expect(tile('Media').getAttribute('data-state')).toBe('checked')
expect(inspector()!.querySelector('[data-testid="slug-lock"]')).toBeNull()
expect(text(inspector())).not.toContain("Can't change after saving")
expect(chip()).toBe('Unsaved changes')
})
it('draws the row as a grip, a 28 px square with the image icon, the label, the key and New', async () => {
await mountAt('/schema/events')
await addMediaField()
const row = rowOf('Thumbnail')!
const square = row.querySelector('[data-field-row] > span')!
expect(row.querySelector('[data-grip]')).not.toBeNull()
expect(square.className).toContain('size-7')
expect(drawsIcon(square, 'image')).toBe(true)
expect(text(row.querySelector('[data-testid="field-row-label"]'))).toBe('Thumbnail')
expect(text(row.querySelector('[data-testid="field-row-key"]'))).toBe('thumbnail')
expect(row.querySelector('[data-testid="field-row-key"]')!.className).toContain('font-mono')
expect(badgesOf('Thumbnail')).toContain('New')
})
it('keeps label, key and Required and resets the rest changing a new row to Media and back', async () => {
await mountAt('/schema/events')
await addField('Thumbnail')
await toggle('Required')
await typeInto(el<HTMLInputElement>('input[name="max"]'), '80')
await pickTile('Media')
expect(labelInput()!.value).toBe('Thumbnail')
expect(fieldKeyInput()!.value).toBe('thumbnail')
expect(text(inspector())).toContain(HINT)
expect(el('input[name="max"]')).toBeNull()
await pickTile('Number')
expect(text(inspector())).not.toContain(HINT)
expect(el<HTMLInputElement>('input[name="max"]')!.value).toBe('')
expect(el<HTMLInputElement>('input[name="min"]')!.value).toBe('')
expect(el<HTMLInputElement>('input[name="default"]')!.value).toBe('')
expect(labelInput()!.value).toBe('Thumbnail')
})
})
describe('The inspector of a saved media field', () => {
it('reads Field: Thumbnail and shows only what applies, in order', async () => {
await mountAt('/schema/events', savedThumbnail())
await selectRow('Thumbnail')
expect(inspectorTitle()).toBe('Field: Thumbnail')
const inside = text(inspector())
const order = [
'Label',
'Key',
"Used in the API, so it can't change.",
'Type',
"Can't change after saving",
'Required',
"An entry can't be saved with this field empty.",
HINT,
].map((part) => inside.indexOf(part))
expect(order.every((index) => index >= 0)).toBe(true)
expect([...order].sort((a, b) => a - b)).toEqual(order)
expect(labelInput()!.value).toBe('Thumbnail')
expect(fieldKeyInput()!.value).toBe('thumbnail')
expect(fieldKeyInput()!.readOnly).toBe(true)
expect(inspector()!.querySelector('[data-testid="slug-lock"]')).not.toBeNull()
})
it('selects Media, dims the six other tiles and makes them unselectable', async () => {
await mountAt('/schema/events', savedThumbnail())
await selectRow('Thumbnail')
expect(all('[role="radio"]')).toHaveLength(7)
expect(tile('Media').getAttribute('data-state')).toBe('checked')
const others = all<HTMLButtonElement>('[role="radio"]').filter(
(radio) => radio !== tile('Media'),
)
expect(others).toHaveLength(6)
expect(others.every((radio) => radio.disabled || radio.hasAttribute('data-disabled'))).toBe(
true,
)
expect(
others.every((radio) => radio.closest('label')!.innerHTML.includes('opacity-40')),
).toBe(true)
await pickTile('Number')
expect(tile('Media').getAttribute('data-state')).toBe('checked')
expect(chip()).toBe('Saved')
})
it('draws the hint as an info block with an info icon', async () => {
await mountAt('/schema/events', savedThumbnail())
await selectRow('Thumbnail')
const hint = el('[data-testid="builder-hint"]')!
expect(text(hint)).toBe(HINT)
expect(hint.className).toContain('bg-(--rc-info-soft)')
expect(drawsIcon(hint, 'info')).toBe(true)
})
it('does not render the options that do not apply', async () => {
await mountAt('/schema/events', savedThumbnail())
await selectRow('Thumbnail')
const inside = text(inspector())
expect(inside).not.toContain('Unique values')
expect(inside).not.toContain('Default value')
expect(inside).not.toContain('Limits')
expect(inside).not.toContain('Shortest')
expect(inside).not.toContain('Choices')
expect(el('input[name="min"]')).toBeNull()
expect(el('input[name="max"]')).toBeNull()
expect(el('input[name="default"]')).toBeNull()
expect(el('[data-testid="choice-row"]')).toBeNull()
})
it('still shows the disabled Unique switch on a Yes or no field', async () => {
await mountAt('/schema/events', savedThumbnail())
await selectRow('Online')
expect(text(inspector())).toContain('Unique values')
expect(text(inspector())).toContain('Limits')
})
})
describe('A required media field', () => {
it('carries the Required marker on its row and the switch on', async () => {
await mountAt('/schema/events', savedThumbnail({ required: true }))
await selectRow('Thumbnail')
const marker = rowOf('Thumbnail')!.querySelector('[aria-label="Required"]')!
expect(text(marker)).toBe('R')
expect(
el('[data-testid="field-inspector"] [role="switch"][aria-checked="true"]'),
).not.toBeNull()
expect(text(inspector())).toContain("An entry can't be saved with this field empty.")
})
it('shows no marker on an optional media field', async () => {
await mountAt('/schema/events', savedThumbnail())
expect(rowOf('Thumbnail')!.querySelector('[aria-label="Required"]')).toBeNull()
})
})
describe('The mobile sheet of a media field', () => {
it('opens the 92 % sheet titled Field: Thumbnail with the inspector, two columns and Done', async () => {
await mountAt('/schema/events', { ...savedThumbnail(), width: MOBILE_WIDTH })
await click(rowOf('Thumbnail')!.querySelector('[data-field-row]'))
expect(text(el('[data-testid="field-sheet-title"]'))).toBe('Field: Thumbnail')
expect(text(el('[role="dialog"]'))).toContain(HINT)
expect(grid().className).toContain('grid-cols-2')
expect(button('Done')).toBeInstanceOf(HTMLButtonElement)
expect(el('[role="dialog"]')!.className).toContain('h-11/12')
})
})
describe('Media in the API preview', () => {
it('shows thumbnail: null and no example text', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
await addMediaField()
await click(el('[data-testid="api-preview-toggle"]'))
const data = JSON.parse(el('[data-testid="api-json"]')!.textContent!).data
expect(data.thumbnail).toBeNull()
expect(Object.keys(data)).toEqual(['title', 'thumbnail'])
expect(el('[data-testid="api-json"]')!.textContent).not.toContain(
'Example text"\n "thumbnail',
)
})
})
describe('Saving a media field', () => {
it('sends the field in the exact shape in the PUT body', async () => {
await mountAt('/schema/events', {
entries: () => ({ items: [], total: 0, page: 1, size: 1 }),
})
await addMediaField()
await click(saveButton())
expect(puts).toHaveLength(1)
const fields = (puts[0]!.body as { fields: unknown[] }).fields
expect(fields[7]).toEqual({
key: 'thumbnail',
label: 'Thumbnail',
type: 'media',
required: false,
unique: false,
default: null,
min: null,
max: null,
options: [],
})
})
it('sends the field in the POST body of a new type', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Gallery')
await selectRow('Title')
await addMediaField()
await toggle('Required')
await click(saveButton())
expect(posts).toHaveLength(1)
const fields = (posts[0]!.body as { fields: Record<string, unknown>[] }).fields
expect(fields[1]).toEqual({
key: 'thumbnail',
label: 'Thumbnail',
type: 'media',
required: true,
unique: false,
default: null,
min: null,
max: null,
options: [],
})
})
it('shows the existing invalid field failure on 422 invalid_field and keeps the edits', async () => {
await mountAt('/schema/events', {
entries: () => ({ items: [], total: 0, page: 1, size: 1 }),
update: failWithBody(422, 'invalid_field'),
})
await addMediaField()
await click(saveButton())
expect(text(document.body)).toContain('Check the fields marked in the list.')
expect(rowOf('Thumbnail')).toBeInstanceOf(HTMLElement)
expect(chip()).toBe('Unsaved changes')
})
it('runs the existing refusal flow on a stale 409 schema_incompatible', async () => {
await mountAt('/schema/events', {
entries: () => ({ items: [], total: 0, page: 1, size: 1 }),
update: failWithBody(409, 'schema_incompatible'),
})
await addMediaField()
await toggle('Required')
await click(saveButton())
expect(text(document.body)).toContain('Nothing was changed')
expect(rowOf('Thumbnail')).toBeInstanceOf(HTMLElement)
})
it('round-trips a saved media field with no change after a save', async () => {
await mountAt('/schema/events', {
...savedThumbnail(),
entries: () => ({ items: [], total: 0, page: 1, size: 1 }),
update: (body) =>
updatedType('events', { ...body, fields: eventsWithThumbnail().fields }),
})
await selectRow('Thumbnail')
await typeInto(labelInput(), 'Cover')
await click(saveButton())
expect(puts).toHaveLength(1)
const sent = (puts[0]!.body as { fields: { label: string; type: string }[] }).fields[7]!
expect(sent.type).toBe('media')
expect(sent.label).toBe('Cover')
})
})
@@ -0,0 +1,303 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
button,
entryPage,
failWithBody,
mountAt,
puts,
text,
thumbnailMount,
unmountAll,
} from './schemaTestKit'
import {
addField,
click,
dialog,
el,
pickTile,
saveButton,
selectRow,
setMax,
toggle,
all,
} from './builderKit'
import { drawsIcon } from './mediaFieldKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
beforeEach(() => {
Element.prototype.scrollIntoView = () => undefined
})
afterEach(async () => {
vi.restoreAllMocks()
await unmountAll()
})
const REFUSED_TEXT =
"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."
const withEntries = (total: number) => ({ entries: () => entryPage(total) })
async function addThumbnail(required: boolean) {
await addField('Thumbnail')
await pickTile('Media')
if (required) {
await toggle('Required')
}
}
async function reviewWith(required: boolean, total = 12, extra: object = {}) {
await mountAt('/schema/events', { ...withEntries(total), ...extra })
await addThumbnail(required)
await click(saveButton())
}
const rowText = () => text(el('[data-testid="change-row"]'))
const apply = () => button('Apply changes') as HTMLButtonElement
const back = () => button('Back to editing') as HTMLButtonElement
describe('A required media field added to a type with entries', () => {
it('opens the review without sending the PUT', async () => {
await reviewWith(true)
expect(puts).toHaveLength(0)
expect(text(dialog())).toContain('Review changes to Events')
expect(text(dialog())).toContain('Apply them to save the content type.')
})
it('reads the summary, the entry count, the row, the flag and the note', async () => {
await reviewWith(true)
expect(text(el('[data-testid="review-summary"]'))).toBe('Add 1 field')
expect(text(dialog())).toContain(
'Events has 12 entries. Each one is checked against the new fields when you apply.',
)
expect(text(el('[data-testid="change-title"]'))).toBe('Add Thumbnail')
expect(text(el('[data-testid="change-flag"]'))).toBe("Can't be applied")
expect(text(el('[data-testid="change-text"]'))).toBe(REFUSED_TEXT)
expect(text(dialog())).toContain('Back to editing keeps your other changes.')
expect(text(dialog())).not.toContain('Flagged changes can be refused')
})
it('draws the flag as text with a warning icon, not only a colour', async () => {
await reviewWith(true)
const flag = el('[data-testid="change-flag"]')!
expect(text(flag)).toBe("Can't be applied")
expect(drawsIcon(flag, 'triangle-alert')).toBe(true)
})
it('keeps Back to editing enabled and announces Apply changes as disabled', async () => {
await reviewWith(true)
expect(back().disabled).toBe(false)
expect(apply().getAttribute('aria-disabled')).toBe('true')
})
it('sends nothing when Apply changes is pressed', async () => {
await reviewWith(true)
await click(apply())
expect(puts).toHaveLength(0)
expect(text(dialog())).toContain('Review changes to Events')
})
it('stays disabled whatever the connection', async () => {
await reviewWith(true)
expect(apply().getAttribute('aria-disabled')).toBe('true')
window.dispatchEvent(new Event('offline'))
await flushPromises()
expect(apply().getAttribute('aria-disabled')).toBe('true')
window.dispatchEvent(new Event('online'))
await flushPromises()
expect(apply().getAttribute('aria-disabled')).toBe('true')
})
it('shows the bottom sheet on mobile', async () => {
await reviewWith(true, 12, { width: MOBILE_WIDTH })
expect(dialog()!.className).toContain('max-sm:')
expect(text(el('[data-testid="change-flag"]'))).toBe("Can't be applied")
})
})
describe('Back to editing after the refusal', () => {
it.each([
['Back to editing', async () => click(back())],
[
'Esc',
async () => {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
)
await flushPromises()
},
],
])('closes with %s, keeps every edit and sends nothing', async (_name, close) => {
await reviewWith(true)
await close()
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(puts).toHaveLength(0)
expect(text(el('#dashboard-content'))).toContain('Thumbnail')
expect(text(el('[data-testid="builder-chip"]'))).toBe('Unsaved changes')
})
it('shows the clean row with Apply enabled once Required is turned off', async () => {
await reviewWith(true)
await click(back())
await vi.waitFor(() => expect(dialog()).toBeNull())
await toggle('Required')
await click(saveButton())
expect(text(el('[data-testid="change-text"]'))).toBe(
'Media, optional. 12 entries are unaffected.',
)
expect(el('[data-testid="change-flag"]')).toBeNull()
expect(apply().getAttribute('aria-disabled')).toBeNull()
expect(apply().disabled).toBe(false)
expect(text(dialog())).not.toContain('Flagged changes can be refused')
expect(text(dialog())).not.toContain('Back to editing keeps your other changes.')
})
})
describe('The clean row', () => {
it.each([
[12, 'Media, optional. 12 entries are unaffected.'],
[1, 'Media, optional. 1 entry is unaffected.'],
])('reads the row for %i entries and enables Apply changes', async (total, line) => {
await reviewWith(false, total)
expect(rowText()).toContain('Add Thumbnail')
expect(text(el('[data-testid="change-text"]'))).toBe(line)
expect(el('[data-testid="change-flag"]')).toBeNull()
expect(apply().disabled).toBe(false)
expect(apply().getAttribute('aria-disabled')).toBeNull()
})
it('applies with the PUT and the media field', async () => {
await reviewWith(false)
await click(apply())
expect(puts).toHaveLength(1)
expect((puts[0]!.body as { fields: { type: string }[] }).fields[7]!.type).toBe('media')
})
it('stays disabled while offline, as before', async () => {
await reviewWith(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
expect(apply().disabled).toBe(true)
window.dispatchEvent(new Event('online'))
await flushPromises()
})
})
describe('When the review is not refused', () => {
it('sends the PUT at once for a type with no entries', async () => {
await mountAt('/schema/events', withEntries(0))
await addThumbnail(true)
await click(saveButton())
expect(dialog()).toBeNull()
expect(puts).toHaveLength(1)
})
it('flags making a saved optional media field required as one that may not fit and keeps Apply enabled', async () => {
await mountAt('/schema/events', { ...thumbnailMount(), ...withEntries(12) })
await selectRow('Thumbnail')
await toggle('Required')
await click(saveButton())
expect(text(el('[data-testid="change-title"]'))).toBe('Change Thumbnail')
expect(text(el('[data-testid="change-text"]'))).toBe('Now required.')
expect(text(el('[data-testid="change-flag"]'))).toBe('Entries may not fit')
expect(apply().disabled).toBe(false)
expect(apply().getAttribute('aria-disabled')).toBeNull()
})
it('lists both rows and disables Apply for a new required Thumbnail with a Capacity change', async () => {
await mountAt('/schema/events', withEntries(12))
await setMax('2000')
await addThumbnail(true)
await click(saveButton())
const titles = all('[data-testid="change-title"]').map(text)
expect(titles).toEqual(['Add Thumbnail', 'Change Capacity'])
expect(apply().getAttribute('aria-disabled')).toBe('true')
expect(text(dialog())).toContain('Back to editing keeps your other changes.')
})
})
describe('The server refuses anyway', () => {
it('runs the existing Nothing was changed flow on a stale 409', async () => {
await mountAt('/schema/events', {
...thumbnailMount(),
...withEntries(12),
update: failWithBody(409, 'schema_incompatible'),
})
await selectRow('Thumbnail')
await toggle('Required')
await click(saveButton())
await click(apply())
expect(puts).toHaveLength(1)
expect(text(dialog())).toContain('Nothing was changed')
expect(text(dialog())).not.toContain('Review changes to Events')
expect(text(dialog())).toContain('Possible cause')
expect(text(dialog())).toContain('Change Thumbnail')
})
})
describe('The flagged changes note of existing reviews', () => {
const NOTE = "Flagged changes can be refused if existing entries don't fit."
it('still shows for an unflagged text field added to a type with entries', async () => {
await mountAt('/schema/events', withEntries(12))
await addField('Speaker')
await click(saveButton())
expect(text(dialog())).toContain(NOTE)
})
it('still shows for a flagged change', async () => {
await mountAt('/schema/events', withEntries(12))
await setMax('2000')
await click(saveButton())
expect(text(dialog())).toContain(NOTE)
})
it('still shows when an optional media field comes with another change', async () => {
await mountAt('/schema/events', withEntries(12))
await addThumbnail(false)
await addField('Speaker')
await click(saveButton())
expect(text(dialog())).toContain(NOTE)
})
})
@@ -160,7 +160,7 @@ describe('New type, desktop (design state 1)', () => {
expect(copy).toContain(
'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.',
)
expect(all('[role="radio"]')).toHaveLength(6)
expect(all('[role="radio"]')).toHaveLength(7)
expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain(
'Text',
)
+105
View File
@@ -0,0 +1,105 @@
import { flushPromises } from '@vue/test-utils'
import type { MediaItem } from '@/composables/useMedia'
import { imageOfItem, type MediaSummary } from '@/lib/mediaField'
import {
answerWrite,
entry42,
fieldWrapper,
lookupNames,
mountEditor,
text,
} from './entryEditorKit'
import { fieldLibrary } from './mediaFieldKit'
import { eventsWithThumbnail } from './schemaTestKit'
import type { EntryWrite, SchemaMount } from './schemaTestKit'
export const LIBRARY_COUNTS = { all: 48, unused: 2, duplicates: 2 }
export function libraryBody(items: MediaItem[] = fieldLibrary()) {
return { items, total: 48, page: 1, size: 25, counts: LIBRARY_COUNTS }
}
export function summaryOf(id: number): MediaSummary {
const item = fieldLibrary().find((candidate) => candidate.id === id)!
return imageOfItem(item)
}
export function entryWithThumbnail(thumbnail: number | null, media?: MediaSummary | 'none') {
const shown =
media === 'none' || thumbnail === null ? undefined : (media ?? summaryOf(thumbnail))
return {
...entry42,
data: { ...entry42.data, thumbnail },
...(shown ? { media: { thumbnail: shown } } : {}),
}
}
export function answerMediaWrite(write: EntryWrite) {
const answered = answerWrite(write)
const id = (write.body.data as Record<string, unknown>).thumbnail
return typeof id === 'number' ? { ...answered, media: { thumbnail: summaryOf(id) } } : answered
}
export function mountMedia(
path: string,
options: SchemaMount & {
required?: boolean
thumbnail?: number | null
media?: MediaSummary | 'none'
} = {},
) {
const { required, thumbnail = null, media, ...rest } = options
const type = eventsWithThumbnail({ required: required ?? false })
return mountEditor(path, {
type: () => type,
content: () => [type],
entry: () => entryWithThumbnail(thumbnail, media),
writeEntry: answerMediaWrite,
library: () => libraryBody(),
lookup: lookupNames,
...rest,
})
}
export function mediaControl() {
return fieldWrapper('thumbnail').querySelector<HTMLElement>('[data-testid="media-control"]')!
}
export function mediaState() {
const box = mediaControl()
if (box.querySelector('[data-testid="media-empty"]')) {
return 'empty'
}
return box.querySelector('[data-testid="media-filled"]') ? 'filled' : 'missing'
}
export function mediaText() {
return text(fieldWrapper('thumbnail'))
}
export function mediaButton(name: string) {
return [...fieldWrapper('thumbnail').querySelectorAll<HTMLButtonElement>('button')].find(
(button) => (button.getAttribute('aria-label') ?? text(button)) === name,
)
}
export function focusedName() {
const active = document.activeElement
return active?.getAttribute('aria-label') ?? text(active)
}
export async function pickFromLibrary(filename: string, opener = 'Change image') {
mediaButton(opener)!.click()
await flushPromises()
await flushPromises()
const tile = [...document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]')].find(
(element) => element.getAttribute('aria-label')!.startsWith(`${filename},`),
)!
tile.click()
await flushPromises()
;[...document.body.querySelectorAll('button')]
.find((button) => text(button) === 'Use this image')!
.click()
await flushPromises()
await flushPromises()
}
+56
View File
@@ -0,0 +1,56 @@
import lucide from 'virtual:lucide-subset'
import type { MediaItem } from '@/composables/useMedia'
import { fixture } from './mediaTestKit'
const SHAPES = 'path, circle, rect, line, polyline, polygon, ellipse'
function shapesOf(root: Element) {
return [...root.querySelectorAll(SHAPES)]
.map((shape) =>
[
shape.tagName.toLowerCase(),
...[...shape.attributes].map((a) => `${a.name}=${a.value}`),
]
.sort()
.join(' '),
)
.join('|')
}
function iconShapes(name: string) {
const holder = document.createElement('div')
holder.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg">${lucide.icons[name]!.body}</svg>`
return shapesOf(holder.firstElementChild!)
}
export function drawsIcon(container: Element | null | undefined, name: string) {
const wanted = iconShapes(name)
return [...(container?.querySelectorAll('svg') ?? [])].some((svg) => shapesOf(svg) === wanted)
}
export function fieldLibrary(): MediaItem[] {
return fixture().map((item) => {
if (item.id === 7) {
return {
...item,
filename: 'lime-plaster-workshop.jpg',
url: '/media/lime-plaster-workshop.jpg',
width: 1600,
height: 900,
size: 245760,
}
}
if (item.id === 8) {
return {
...item,
filename: 'bathroom-tiles.png',
url: '/media/bathroom-tiles.png',
content_type: 'image/png',
width: 800,
height: 600,
size: 51200,
}
}
return item
})
}
+29 -1
View File
@@ -11,7 +11,7 @@ import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { useContentTypesStore } from '@/stores/contentTypes'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
import { apiField, eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
export { apiField, eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
@@ -44,6 +44,21 @@ export const customTypes = [
},
].map((type) => ({ ...type, created_at: '2026-09-01T09:30:00Z' }))
export function thumbnailField(rest: object = {}) {
return apiField('Thumbnail', 'thumbnail', 'media', { options: [], ...rest })
}
export function eventsWithThumbnail(rest: object = {}) {
return { ...customTypes[0]!, fields: [...eventsFields, thumbnailField(rest)] }
}
export function thumbnailMount(rest: object = {}) {
return {
content: () => [eventsWithThumbnail(rest), customTypes[1]!, customTypes[2]!],
type: (key: string) => (key === 'events' ? eventsWithThumbnail(rest) : customTypes[1]),
}
}
function articleField(label: string, key: string, type: string, rest: object = {}) {
return {
key,
@@ -122,6 +137,14 @@ export interface PostedRequest {
export const entryCounts: Record<string, number> = { events: 37, recipes: 0 }
export const emptyLibrary = {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 0, unused: 0, duplicates: 0 },
}
export function entryPage(total: number) {
return { items: [], total, page: 1, size: 1 }
}
@@ -157,6 +180,7 @@ export interface SchemaMount {
type?: (key: string) => unknown
list?: (key: string, query: URLSearchParams) => unknown
lookup?: (ids: number[]) => unknown
library?: (query: URLSearchParams) => unknown
before?: () => void
}
@@ -226,6 +250,10 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
return (options.create ?? (() => createdType(body)))(body)
}
calls.push(url)
const library = url.match(/^\/v1\/media\/\?(.*)$/)
if (library) {
return (options.library ?? (() => emptyLibrary))(new URLSearchParams(library[1]))
}
if (url === '/v1/content-type') {
return (options.content ?? (() => [...customTypes]))()
}