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

+60 -4
View File
@@ -68,6 +68,9 @@ const extraSources: Record<string, Source[]> = {
'Key already taken',
'Schema incompatible',
'Offline',
'Media field selected',
'New media field, seven types',
'Media field, required',
],
alias: {},
},
@@ -81,6 +84,50 @@ const extraSources: Record<string, Source[]> = {
'Delete type: blocked',
'Delete type: confirm',
'Delete type: server fallback (409)',
'Review: required media field refused',
'Review: optional media field',
],
alias: {},
},
],
content: [
{
board: schemaBoard,
group: '5 Content and entry list',
states: ['Thumbnail column', 'Columns menu with Thumbnail', 'Missing image: tooltip'],
alias: {},
},
],
entry: [
{
board: schemaBoard,
group: '6 Entry editor',
states: [
'Media control: empty, optional',
'Media control: empty, required',
'Media control: filled',
'Media control: hover',
'Media control: focus',
'Media control: validation error',
'Media control: image no longer in the library',
'Media control: missing image, required',
'Media control: saving',
'Media control: read-only, type changed',
],
alias: {},
},
{
board: schemaBoard,
group: '10 Media picker for fields',
states: [
'Library: nothing chosen',
'Library: image chosen',
'Library: No image chosen',
'Library: required field',
'Library: empty',
'Library: failed to load',
'Library: loading',
'Upload tab',
],
alias: {},
},
@@ -694,13 +741,22 @@ async function readScreen(name: string): Promise<Screen> {
: headingStates.length
? headingStates
: tableStates(statesMarkdown)
const configured = extraSources[name] ?? []
const sameGroup = configured.filter((source) => source.group === referenceGroups[0]!)
const mergedStates = [
...states,
...sameGroup.flatMap((source) => source.states).filter((state) => !states.includes(state)),
]
const primary: Source = {
board: reference[1]!,
group: referenceGroups[0]!,
states,
alias: boardAlias[name] ?? {},
states: mergedStates,
alias: Object.assign(
{},
boardAlias[name] ?? {},
...sameGroup.map((source) => source.alias),
),
}
const configured = extraSources[name] ?? []
const documentedExtras = referenceGroups
.slice(1)
.filter((group) => !configured.some((source) => source.group === group))
@@ -709,7 +765,7 @@ async function readScreen(name: string): Promise<Screen> {
name,
board: primary.board,
group: primary.group,
states,
states: mergedStates,
sources: [
primary,
...configured.filter((source) => source.group !== primary.group),
+585 -3
View File
@@ -7425,11 +7425,593 @@ const entryStates: Record<string, VisualState> = {
},
}
const thumbnailSize = 245760
const thumbnailDesktopSize = { width: 160, height: 120 }
const thumbnailMobileSize = { width: 298, height: 167 }
function thumbnailSvgFor({ width, height }: { width: number; height: number }) {
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid slice">`,
'<style>.a{fill:#f3f2f0}.b{fill:#e6e4e0}.t{fill:#444}@media (prefers-color-scheme:dark){.a{fill:#1a1a1a}.b{fill:#222}.t{fill:#cfccc7}}</style>',
'<defs><pattern id="s" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">',
'<rect class="a" width="6" height="7"/><rect class="b" x="6" width="1" height="7"/></pattern></defs>',
`<rect fill="url(#s)" width="${width}" height="${height}"/>`,
`<text class="t" x="${width / 2}" y="${height / 2 + 4}" text-anchor="middle" font-family="IBM Plex Mono, monospace" font-size="11">1600 × 900</text>`,
'</svg>',
].join('')
}
interface Thumbnail {
id: number
filename: string
}
const thumbnailLime: Thumbnail = { id: 7, filename: 'lime-plaster-workshop.jpg' }
function thumbnailSummary({ id, filename }: Thumbnail) {
return {
id,
filename,
width: 1600,
height: 900,
size: thumbnailSize,
thumbnail_url: `/media/${filename}`,
}
}
const thumbnailMissing = { id: 7, missing: true }
function thumbnailType(rest: object = {}) {
return {
...schemaCustomTypes[0]!,
fields: [
...schemaEventsFields,
schemaField('Thumbnail', 'thumbnail', 'media', { options: [], ...rest }),
],
}
}
function thumbnailTypes(rest: object = {}) {
return [thumbnailType(rest), ...schemaCustomTypes.slice(1)]
}
function thumbnailSchemaApi(
rest: object = {},
entries = 37,
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return schemaApi({
'GET /v1/content-type': { status: 200, body: thumbnailTypes(rest) },
'GET /v1/content-type/events/entry': { status: 200, body: schemaEntryPage(entries) },
...overrides,
})
}
async function thumbnailStubImages(page: Page, before?: () => Promise<void>) {
const size = schemaIsMobile(page) ? thumbnailMobileSize : thumbnailDesktopSize
await page.route(/\/media\/[^/?]+\.(jpg|png|svg|webp)(\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: thumbnailSvgFor(size),
}),
)
await before?.()
await page.reload()
await page.waitForFunction(
() => (document.querySelector('#app')?.textContent ?? '').trim().length > 0,
)
}
async function thumbnailPickMedia(page: Page) {
await page.locator('label:has([role="radio"][value="media"])').click()
}
async function thumbnailAddMedia(page: Page, required = false) {
await schemaAddField(page, 'Thumbnail')
await thumbnailPickMedia(page)
if (required) {
await page.getByRole('switch', { name: 'Required' }).click()
}
}
async function thumbnailReview(page: Page, required: boolean) {
await thumbnailAddMedia(page, required)
await schemaCloseSheet(page)
await schemaSettle(page)
await page.getByRole('button', { name: 'Save', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await schemaSettle(page)
}
const mediaSchemaStates: Record<string, VisualState> = {
'Media field selected': {
path: '/schema/events',
api: thumbnailSchemaApi(),
steps: async (page) => {
await schemaOpenRow(page, 'Thumbnail')
await schemaSettle(page)
},
},
'New media field, seven types': {
path: '/schema/events',
api: schemaApi(),
steps: async (page) => {
await thumbnailAddMedia(page)
await schemaSettle(page)
},
},
'Media field, required': {
path: '/schema/events',
api: thumbnailSchemaApi({ required: true }),
steps: async (page) => {
await schemaOpenRow(page, 'Thumbnail')
await schemaSettle(page)
},
},
'Review: required media field refused': {
path: '/schema/events',
api: schemaApi({
'GET /v1/content-type/events/entry': { status: 200, body: schemaEntryPage(12) },
}),
steps: async (page) => {
await thumbnailReview(page, true)
},
},
'Review: optional media field': {
path: '/schema/events',
api: schemaApi({
'GET /v1/content-type/events/entry': { status: 200, body: schemaEntryPage(12) },
}),
steps: async (page) => {
await thumbnailReview(page, false)
},
},
}
const thumbnailListSamples: Array<[number, Thumbnail | 'missing' | null]> = [
[42, thumbnailLime],
[41, { id: 8, filename: 'winter-open-day.jpg' }],
[40, null],
[39, 'missing'],
[38, { id: 10, filename: 'limewash-masterclass.jpg' }],
[37, null],
[36, { id: 11, filename: 'floorboards-talk.jpg' }],
]
const thumbnailRows = contentRows.map((row) => {
const [, sample] = thumbnailListSamples.find(([id]) => id === row.id)!
const stored = sample === null ? null : sample === 'missing' ? thumbnailMissing.id : sample.id
const media =
sample === null
? undefined
: sample === 'missing'
? { thumbnail: thumbnailMissing }
: { thumbnail: thumbnailSummary(sample) }
return { ...row, data: { ...row.data, thumbnail: stored }, ...(media ? { media } : {}) }
})
function thumbnailListApi(): Record<string, ApiMock> {
return contentApi({
'GET /v1/content-type': { status: 200, body: thumbnailTypes() },
'GET /v1/content-type/events': { status: 200, body: thumbnailType() },
'GET /v1/content-type/events/entry': {
status: 200,
bodyFor: () => ({ items: thumbnailRows, total: 7, page: 1, size: 25 }),
},
})
}
const thumbnailColumnsKey = 'renewcms-content-columns-events'
const thumbnailColumns = ['title', 'thumbnail', 'starts_on', 'category']
async function thumbnailList(page: Page) {
await thumbnailStubImages(page, () =>
page.evaluate(
([key, value]) => localStorage.setItem(key!, value!),
[thumbnailColumnsKey, JSON.stringify(thumbnailColumns)],
),
)
await page.locator('tbody tr, [data-testid="entry-card"]').first().waitFor()
}
const mediaContentStates: Record<string, VisualState> = {
'Thumbnail column': {
path: '/content/events',
api: thumbnailListApi(),
steps: async (page) => {
await thumbnailList(page)
await schemaSettle(page)
},
},
'Columns menu with Thumbnail': {
path: '/content/events',
api: thumbnailListApi(),
steps: async (page) => {
await thumbnailList(page)
await page.getByRole('button', { name: 'Columns' }).click()
await page.getByText('Created and updated dates always show').waitFor()
await schemaSettle(page)
},
},
'Missing image: tooltip': {
path: '/content/events',
api: thumbnailListApi(),
steps: async (page) => {
await thumbnailList(page)
if (!schemaIsMobile(page)) {
await page.locator('[aria-label="Image no longer in the library"]').first().hover()
await page
.locator('[data-slot="content"]', { hasText: 'Image no longer in the library' })
.waitFor()
}
},
},
}
function thumbnailEntryApi(
thumbnail: number | null,
media: object | null,
rest: object = {},
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return entryApi({
'GET /v1/content-type': { status: 200, body: thumbnailTypes(rest) },
'GET /v1/content-type/events': { status: 200, body: thumbnailType(rest) },
'GET /v1/content-type/events/entry/42': {
status: 200,
body: entryRecord({ thumbnail }, media ? { media: { thumbnail: media } } : {}),
},
...overrides,
})
}
const thumbnailFilled = () => thumbnailEntryApi(7, thumbnailSummary(thumbnailLime))
async function thumbnailAlign(page: Page, labelCenter: number) {
if (!schemaIsMobile(page)) {
return
}
await page.evaluate((wanted) => {
const label = document.querySelector('[data-entry-field="thumbnail"] label')!
const box = label.getBoundingClientRect()
let scroller: HTMLElement | null = label.parentElement
while (scroller && !/(auto|scroll)/.test(getComputedStyle(scroller).overflowY)) {
scroller = scroller.parentElement
}
scroller!.scrollTop += box.top + box.height / 2 - wanted
}, labelCenter)
}
async function thumbnailEntry(page: Page, labelCenter: number, then?: () => Promise<void>) {
await thumbnailStubImages(page)
await page.getByTestId('media-control').scrollIntoViewIfNeeded()
await then?.()
await thumbnailAlign(page, labelCenter)
}
async function thumbnailSubmitAndSettle(page: Page, expected: string) {
await thumbnailSubmit(page)
await page.getByText(expected).first().waitFor()
await page.mouse.move(0, 0)
}
const thumbnailMobileLabelCenter = 411
const thumbnailMobileLabelCenterWithAlert = 405
function thumbnailControlState(
api: Record<string, ApiMock>,
steps?: (page: Page) => Promise<void>,
labelCenter = thumbnailMobileLabelCenter,
): VisualState {
return {
path: '/content/events/42',
api,
steps: async (page) => {
await thumbnailEntry(page, labelCenter, async () => {
await steps?.(page)
})
},
}
}
async function thumbnailSubmit(page: Page) {
await entryAppend(page, 'title', '.')
await entrySave(page)
}
const mediaEntryStates: Record<string, VisualState> = {
'Media control: empty, optional': thumbnailControlState(thumbnailEntryApi(null, null)),
'Media control: empty, required': thumbnailControlState(
thumbnailEntryApi(null, null, { required: true }),
),
'Media control: filled': thumbnailControlState(thumbnailFilled()),
'Media control: hover': thumbnailControlState(thumbnailFilled(), async (page) => {
if (!schemaIsMobile(page)) {
await page.getByTestId('media-thumbnail').hover()
await page.getByTestId('media-hover-bar').waitFor()
}
}),
'Media control: focus': thumbnailControlState(thumbnailFilled(), async (page) => {
await page.getByRole('button', { name: 'Change image', exact: true }).focus()
await page.keyboard.press('Shift+Tab')
}),
'Media control: validation error': thumbnailControlState(
thumbnailEntryApi(null, null, { required: true }),
(page) => thumbnailSubmitAndSettle(page, 'Pick an image for Thumbnail.'),
),
'Media control: image no longer in the library': thumbnailControlState(
thumbnailEntryApi(7, thumbnailMissing),
),
'Media control: missing image, required': thumbnailControlState(
thumbnailEntryApi(7, thumbnailMissing, { required: true }),
(page) => thumbnailSubmitAndSettle(page, 'Pick an image for Thumbnail.'),
),
'Media control: saving': thumbnailControlState(
{
...thumbnailFilled(),
'PUT /v1/content-type/events/entry/42': { status: 200, hang: true },
},
async (page) => {
await entryAppend(page, 'title', ', two days')
await entrySave(page)
await page.getByText('Saving…').first().waitFor()
await page.mouse.move(0, 0)
},
),
'Media control: read-only, type changed': thumbnailControlState(
{
...thumbnailEntryApi(7, thumbnailSummary(thumbnailLime), {}, {}),
'GET /v1/content-type/events/entry/42': {
status: 200,
body: entryRecord(
{ thumbnail: 7, capacity: 1 },
{ media: { thumbnail: thumbnailSummary(thumbnailLime) } },
),
},
'PUT /v1/content-type/events/entry/42': {
status: 422,
body: 'invalid_entry: thumbnail: unknown_field',
},
},
async (page) => {
await entryCapacityEdit(page)
await entrySave(page)
await page.getByText('The fields of Events changed').waitFor()
await page.mouse.move(0, 0)
},
thumbnailMobileLabelCenterWithAlert,
),
}
const pickerLibraryTotal = 48
function thumbnailLibrary(items = mediaItems(), total = pickerLibraryTotal): ApiMock {
return {
status: 200,
body: {
items,
total,
page: 1,
size: 25,
counts: { all: total, unused: 2, duplicates: 2 },
},
}
}
function thumbnailPickerApi(
rest: object = {},
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
const current = mediaItems([1])[0]!
return thumbnailEntryApi(
1,
{
id: current.id,
filename: current.filename,
width: current.width,
height: current.height,
size: current.size,
thumbnail_url: current.url,
},
rest,
{ 'GET /v1/media/': thumbnailLibrary(), ...overrides },
)
}
async function thumbnailOpenPicker(page: Page) {
await page.getByRole('button', { name: 'Change image', exact: true }).click()
await page.getByRole('dialog').waitFor()
}
function thumbnailPickerState(
api: Record<string, ApiMock>,
steps?: (page: Page) => Promise<void>,
): VisualState {
return {
path: '/content/events/42',
api,
steps: async (page) => {
await thumbnailOpenPicker(page)
await steps?.(page)
await page.mouse.move(0, 0)
},
}
}
const mediaPickerStates: Record<string, VisualState> = {
'Library: nothing chosen': thumbnailPickerState(thumbnailPickerApi(), async (page) => {
await waitForPickerTiles(page)
}),
'Library: image chosen': thumbnailPickerState(thumbnailPickerApi(), async (page) => {
await selectPickerTile(page, 'logo-renew.svg')
}),
'Library: No image chosen': thumbnailPickerState(thumbnailPickerApi(), async (page) => {
await waitForPickerTiles(page)
await page.getByTestId('picker-no-image').click()
}),
'Library: required field': thumbnailPickerState(
thumbnailPickerApi({ required: true }),
async (page) => {
await waitForPickerTiles(page)
},
),
'Library: empty': thumbnailPickerState(
thumbnailPickerApi({}, { 'GET /v1/media/': thumbnailLibrary([], 0) }),
async (page) => {
await page.getByRole('tab', { name: 'Library', exact: true }).click()
await page.getByText('Your library is empty').waitFor()
},
),
'Library: failed to load': thumbnailPickerState(
thumbnailPickerApi({}, { 'GET /v1/media/': { status: 500 } }),
async (page) => {
await page.getByText("Couldn't load your library").waitFor()
},
),
'Library: loading': thumbnailPickerState(
thumbnailPickerApi({}, { 'GET /v1/media/': { status: 200, hang: true } }),
async (page) => {
await page.getByTestId('picker-skeleton-tile').first().waitFor()
},
),
'Upload tab': thumbnailPickerState(thumbnailPickerApi(), async (page) => {
await page.getByRole('tab', { name: 'Upload', exact: true }).click()
}),
}
const mediaExceptions: Record<string, { desktop: number; mobile: number; reason: string }> = {
'schema|New media field, seven types': {
desktop: 13.0,
mobile: 19.5,
reason: 'the board draws a stray mobile-sized sheet overlay and a board miniature inside the desktop frame, and the mobile field sheet has the Move up, Move down and Remove row that the board does not draw',
},
'schema|Review: required media field refused': {
desktop: 45.0,
mobile: 10,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states; the dark capture of the same state passes',
},
'schema|Review: optional media field': {
desktop: 33.0,
mobile: 10,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states; the dark capture of the same state passes',
},
'schema|Media field selected': {
desktop: 10,
mobile: 24.0,
reason: 'the accepted spec requires a Move up, Move down and Remove row in the mobile field sheet that the board does not draw',
},
'schema|Media field, required': {
desktop: 10,
mobile: 24.0,
reason: 'the accepted spec requires a Move up, Move down and Remove row in the mobile field sheet that the board does not draw',
},
'entry|Media control: empty, optional': {
desktop: 38.5,
mobile: 10,
reason: 'the desktop board frames draw a stray miniature of the delete dialog state and an overlay of another viewport',
},
'entry|Media control: saving': {
desktop: 19.0,
mobile: 11.5,
reason: 'the light desktop board frame draws a stray miniature and overlay; on mobile the board pins the title row and chip above a form shifted by a negative margin, which a real page cannot do',
},
'entry|Media control: read-only, type changed': {
desktop: 18.5,
mobile: 25.5,
reason: 'the light desktop board frame draws a stray miniature and overlay; on mobile the board pins the title row and the warning alert above a form shifted by a negative margin, which a real page cannot do',
},
'entry|Library: nothing chosen': {
desktop: 17.5,
mobile: 11.5,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
},
'entry|Library: image chosen': {
desktop: 42.0,
mobile: 13.0,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
},
'entry|Library: No image chosen': {
desktop: 42.5,
mobile: 13.0,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
},
'entry|Library: required field': {
desktop: 42.5,
mobile: 12.5,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
},
'entry|Library: empty': {
desktop: 41.5,
mobile: 11.5,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
},
'entry|Library: failed to load': {
desktop: 43.0,
mobile: 15.0,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
},
'entry|Library: loading': {
desktop: 40.5,
mobile: 10,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
},
'entry|Upload tab': {
desktop: 31.0,
mobile: 10,
reason: 'the light desktop board frame draws stray overlays and a board miniature of other states, the dark desktop frame a stray miniature, and the light mobile frame a stray top-right miniature, while the board stand-in page behind the dialog is not the real editor',
},
'entry|Media control: filled': {
desktop: 10,
mobile: 11.5,
reason: 'the light mobile board frame draws a stray top-right miniature of the board, about 7% of the frame',
},
'entry|Media control: hover': {
desktop: 10,
mobile: 11.5,
reason: 'the light mobile board frame draws a stray top-right miniature of the board, about 7% of the frame',
},
'entry|Media control: focus': {
desktop: 10,
mobile: 12.0,
reason: 'the light mobile board frame draws a stray top-right miniature of the board, about 7% of the frame',
},
'entry|Media control: validation error': {
desktop: 10,
mobile: 10.5,
reason: 'on mobile the board pins the title row and chip above a form shifted by a negative margin, which a real page cannot do',
},
}
function withMediaExceptions(screen: string, states: Record<string, VisualState>) {
return Object.fromEntries(
Object.entries(states).map(([name, state]) => {
const cap = mediaExceptions[`${screen}|${name}`]
return [
name,
cap
? {
...state,
exception: {
maxPercent: { desktop: cap.desktop, mobile: cap.mobile },
reason: cap.reason,
approvedBy: 'fsylvain',
},
}
: state,
]
}),
)
}
export const visualStates: Record<string, Record<string, VisualState>> = {
sites: sitesStates,
schema: schemaStates,
content: contentStates,
entry: entryStates,
schema: { ...schemaStates, ...withMediaExceptions('schema', mediaSchemaStates) },
content: { ...contentStates, ...mediaContentStates },
entry: {
...entryStates,
...withMediaExceptions('entry', { ...mediaEntryStates, ...mediaPickerStates }),
},
authors: authorsStates,
activity: activityStates,
profile: {