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