feat: image variants settings screens

This commit is contained in:
fsylvain committed 2026-10-08 13:57:41 +02:00
1 parent f1d56ef50e
commit f567866161
42 files changed
+7192 -11

No files matched your search

+65
View File
@@ -186,7 +186,72 @@ function tableStates(markdown: string) {
.filter((cell) => cell !== 'State' && !/^-+$/.test(cell))
}
const variantsBoard = 'RenewCMS Screens Image variants.dc.html'
function numberedStates(prefix: string, names: string[]) {
return Object.fromEntries(
names.map((state, index) => [
state,
`${prefix}${String.fromCharCode(97 + index)} · ${state}`,
]),
)
}
function variantsScreen(): Screen {
const listNames = [
'Loaded',
'Loading',
'Empty',
'Error loading',
'Forbidden',
'Row menu',
'Row action in progress',
'Purge confirm',
'Delete confirm',
'Delete in progress',
'Request reference',
'Entry: sidebar',
]
const formNames = [
'Create: pristine',
'Create: dirty',
'Field errors',
'No dimension',
'Name already taken',
'Saving',
'Save error',
'Edit: pristine',
'Edit: dirty',
'Unsaved changes',
'PNG: no quality',
]
const sources: Source[] = [
{
board: variantsBoard,
group: 'Variants list',
states: listNames,
alias: numberedStates('1', listNames),
},
{
board: variantsBoard,
group: 'Add edit variant',
states: formNames,
alias: numberedStates('2', formNames),
},
]
return {
name: 'image-variants',
board: variantsBoard,
group: 'Variants list',
states: [...listNames, ...formNames],
sources,
}
}
async function readScreen(name: string): Promise<Screen> {
if (name === 'image-variants') {
return variantsScreen()
}
const markdown = await readFile(join(designRoot, 'screens', `${name}.md`), 'utf8')
const reference = /\*\*Visual reference:\*\* `source\/([^`]+)`, group "([^"]+)"/.exec(markdown)
if (!reference) {
+334
View File
@@ -3095,6 +3095,339 @@ const activityStates: Record<string, VisualState> = {
Details: activityClockSensitive,
}
const variantsPath = '/settings/media/variants'
const variantsMegabyte = 1048576
const variantsSession: ApiMock = {
status: 200,
body: {
id: 1,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: 'Marta Alves',
role: 'owner',
},
}
function boardVariant(
name: string,
width: number | null,
height: number | null,
fit: string,
format: string,
quality: number | null,
files: number,
megabytes: number,
enabled = true,
) {
return {
name,
width,
height,
fit,
format,
quality,
generate_on_upload: false,
enabled,
generated_files: files,
cache_size: megabytes * variantsMegabyte,
created_at: '2026-03-12T09:00:00Z',
updated_at: '2026-03-12T09:00:00Z',
}
}
const variantsBoardList = [
boardVariant('thumb', 400, 300, 'cover', 'webp', 80, 412, 21),
boardVariant('card', 800, null, 'inside', 'webp', 80, 318, 58),
boardVariant('hero', 1200, 630, 'cover', 'webp', 82, 244, 84),
boardVariant('square', 600, 600, 'cover', 'jpeg', 85, 140, 19),
boardVariant('og', 1200, 630, 'cover', 'jpeg', 80, 96, 9),
boardVariant('print', null, 2400, 'inside', 'png', null, 74, 21, false),
]
function variantsApi(
items: unknown[] = variantsBoardList,
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': variantsSession,
'GET /v1/image-variants': { status: 200, body: { items } },
...overrides,
}
}
function variantsListState(
overrides: Record<string, ApiMock> = {},
steps?: VisualState['steps'],
items: unknown[] = variantsBoardList,
): VisualState {
return { path: variantsPath, api: variantsApi(items, overrides), steps }
}
async function openVariantMenu(page: Page, name: string) {
await page.getByRole('button', { name: `Actions for ${name}`, exact: true }).click()
await page.getByRole('menu').waitFor()
}
async function chooseVariantMenuItem(page: Page, name: string, item: string) {
await openVariantMenu(page, name)
await page.getByRole('menuitem', { name: item, exact: true }).click()
}
async function scrollVariantsPage(page: Page, offset: number) {
await page.evaluate((value) => {
const body = document
.querySelector<HTMLElement>('#dashboard-content')
?.closest<HTMLElement>('[data-slot="body"]')
if (body) body.scrollTop = value
}, offset)
await page.waitForTimeout(150)
}
async function scrollVariantsForm(page: Page, offset: number) {
await page.evaluate((value) => {
const body = document.querySelector<HTMLElement>('[role="dialog"] [data-slot="body"]')
if (body) body.scrollTop = value
}, offset)
await page.waitForTimeout(150)
}
function isMobileViewport(page: Page) {
return (page.viewportSize()?.width ?? 0) < 640
}
async function openVariantForm(page: Page) {
await page.getByRole('button', { name: 'Add variant', exact: true }).first().click()
await page.getByRole('dialog').waitFor()
}
function variantField(page: Page, name: string) {
return page.locator(`[data-field="${name}"] input:not([aria-hidden="true"])`)
}
async function pickVariantFormat(page: Page, label: string) {
await page.getByRole('dialog').getByRole('combobox').click()
await page.getByRole('option', { name: label, exact: true }).click()
}
interface VariantFormFill {
name: string
width: string
height: string
format: string
quality?: string
generate?: boolean
}
async function fillVariantForm(page: Page, values: VariantFormFill) {
await variantField(page, 'name').fill(values.name)
await variantField(page, 'width').fill(values.width)
await variantField(page, 'height').fill(values.height)
await pickVariantFormat(page, values.format)
if (values.quality !== undefined) {
await variantField(page, 'quality').fill(values.quality)
}
if (values.generate) {
await page.getByRole('switch', { name: 'Generate on upload', exact: true }).click()
}
}
const heroForm: VariantFormFill = {
name: 'hero',
width: '1200',
height: '630',
format: 'WebP',
quality: '82',
generate: true,
}
async function blurFocus(page: Page) {
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await page.waitForTimeout(100)
}
async function pressVariantFormButton(page: Page, label: string) {
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click()
}
async function openEditHero(page: Page) {
await chooseVariantMenuItem(page, 'hero', 'Edit')
await page.getByRole('dialog').waitFor()
}
const variantsStates: Record<string, VisualState> = {
Loaded: variantsListState(),
Loading: {
path: variantsPath,
api: variantsApi(variantsBoardList, {
'GET /v1/image-variants': { status: 200, hang: true },
}),
},
Empty: variantsListState({}, undefined, []),
'Error loading': variantsListState({ 'GET /v1/image-variants': { status: 500 } }),
Forbidden: {
path: variantsPath,
api: {
'GET /v1/auth/me': {
status: 200,
body: { ...(variantsSession.body as object), role: 'user' },
},
},
},
'Row menu': variantsListState({}, (page) => openVariantMenu(page, 'hero')),
'Row action in progress': variantsListState(
{ 'POST /v1/image-variants/hero/purge': { status: 200, hang: true } },
async (page) => {
await chooseVariantMenuItem(page, 'hero', 'Purge cache')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Purge 244 files' })
.click()
await page.mouse.move(0, 0)
},
),
'Purge confirm': variantsListState({}, async (page) => {
await chooseVariantMenuItem(page, 'hero', 'Purge cache')
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}),
'Delete confirm': variantsListState({}, async (page) => {
await chooseVariantMenuItem(page, 'hero', 'Delete')
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}),
'Delete in progress': variantsListState(
{ 'DELETE /v1/image-variants/hero': { status: 204, hang: true } },
async (page) => {
await chooseVariantMenuItem(page, 'hero', 'Delete')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete variant' })
.click()
await page.mouse.move(0, 0)
},
),
'Entry: sidebar': {
path: '/media',
origin: mediaOrigin,
api: mediaApi({ 'GET /v1/auth/me': variantsSession }),
steps: async (page) => {
if (isMobileViewport(page)) {
await page.locator('[data-slot="toggle"]').first().click()
}
await page
.locator(
'nav[aria-label="Main"]:visible a[href="/settings"] [data-slot="linkTrailing"]',
)
.click()
await page.mouse.move(0, 0)
await page.waitForTimeout(250)
},
},
'Request reference': {
...variantsListState({}, async (page) => {
if (isMobileViewport(page)) {
await scrollVariantsPage(page, 900)
}
}),
exception: {
maxPercent: 21,
reason: 'board draws /media/photo.png?v=thumb (2 lines) and the mandated /static/uploadedImages/photo.png?v=thumb wraps to 3; board scroll 900 is unreachable (page max ~861)',
approvedBy: 'user',
},
},
'Create: pristine': variantsListState({}, openVariantForm),
'Create: dirty': variantsListState({}, async (page) => {
await openVariantForm(page)
await fillVariantForm(page, heroForm)
await page.locator('input[type="range"]').focus()
if (isMobileViewport(page)) {
await scrollVariantsForm(page, 560)
}
}),
'Field errors': variantsListState({}, async (page) => {
await openVariantForm(page)
await fillVariantForm(page, {
name: 'Hero banner',
width: '9000',
height: '630',
format: 'WebP',
quality: '140',
})
await variantField(page, 'name').blur()
await variantField(page, 'width').blur()
await variantField(page, 'quality').blur()
await blurFocus(page)
}),
'No dimension': variantsListState({}, async (page) => {
await openVariantForm(page)
await variantField(page, 'name').fill('banner')
await pickVariantFormat(page, 'WebP')
await pressVariantFormButton(page, 'Add variant')
await blurFocus(page)
}),
'Name already taken': variantsListState(
{
'POST /v1/image-variants': {
status: 409,
body: 'A variant named “thumb” already exists. Choose another name.',
},
},
async (page) => {
await openVariantForm(page)
await fillVariantForm(page, {
name: 'thumb',
width: '400',
height: '300',
format: 'WebP',
quality: '80',
generate: true,
})
await pressVariantFormButton(page, 'Add variant')
await page.getByText('already exists').waitFor()
await blurFocus(page)
},
),
Saving: variantsListState(
{ 'POST /v1/image-variants': { status: 201, hang: true } },
async (page) => {
await openVariantForm(page)
await fillVariantForm(page, heroForm)
await pressVariantFormButton(page, 'Add variant')
await page.mouse.move(0, 0)
},
),
'Save error': variantsListState(
{ 'POST /v1/image-variants': { status: 500 } },
async (page) => {
await openVariantForm(page)
await fillVariantForm(page, heroForm)
await pressVariantFormButton(page, 'Add variant')
await page.getByText("Couldn't save the variant").waitFor()
await page.mouse.move(0, 0)
},
),
'Edit: pristine': variantsListState({}, openEditHero),
'Edit: dirty': variantsListState({}, async (page) => {
await openEditHero(page)
await variantField(page, 'quality').fill('70')
await blurFocus(page)
}),
'Unsaved changes': variantsListState({}, async (page) => {
await openEditHero(page)
await variantField(page, 'quality').fill('70')
await pressVariantFormButton(page, 'Cancel')
await page.getByRole('alertdialog').waitFor()
}),
'PNG: no quality': variantsListState({}, async (page) => {
await openVariantForm(page)
await fillVariantForm(page, { ...heroForm, quality: undefined })
await pickVariantFormat(page, 'PNG')
await blurFocus(page)
}),
}
export const visualStates: Record<string, Record<string, VisualState>> = {
activity: activityStates,
profile: {
@@ -4233,6 +4566,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: submitAcceptInvite('lea', 'limewash2026'),
},
},
'image-variants': variantsStates,
media: { ...mediaStates, ...mediaPurgeStates },
'media-picker': pickerStates,
}