feat(SPA): media page T0
This commit is contained in:
1 parent
0d99544579
commit
67d456baf6
32 files changed
+4874
-40
No files matched your search
+430
-1
@@ -3,6 +3,7 @@ import type { Page } from 'playwright'
|
||||
export interface ApiMock {
|
||||
status: number
|
||||
body?: unknown
|
||||
bodyFor?: (url: URL, call: number) => unknown
|
||||
headers?: Record<string, string>
|
||||
hang?: boolean
|
||||
}
|
||||
@@ -15,7 +16,7 @@ export interface DiffException {
|
||||
|
||||
export interface VisualState {
|
||||
exception?: DiffException
|
||||
skip?: 'clock-sensitive'
|
||||
skip?: 'clock-sensitive' | 'pending'
|
||||
path: string
|
||||
origin?: string
|
||||
now?: string
|
||||
@@ -805,6 +806,433 @@ const usersWithDisplayNames = usersFixture.map((item) =>
|
||||
: item,
|
||||
)
|
||||
|
||||
interface MediaSample {
|
||||
id: number
|
||||
filename: string
|
||||
size: number
|
||||
width: number | null
|
||||
height: number | null
|
||||
contentType: string
|
||||
usedIn: number
|
||||
uploadedBy: string
|
||||
createdAt: string
|
||||
duplicate: boolean
|
||||
tone: number
|
||||
}
|
||||
|
||||
const mediaSamples: MediaSample[] = [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'north-wall.jpg',
|
||||
size: 4089446,
|
||||
width: 4032,
|
||||
height: 3024,
|
||||
contentType: 'image/jpeg',
|
||||
usedIn: 2,
|
||||
uploadedBy: 'Marta Alves',
|
||||
createdAt: '2026-09-12T12:00:00Z',
|
||||
duplicate: false,
|
||||
tone: 0,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
filename: 'kitchen-after.webp',
|
||||
size: 1258291,
|
||||
width: 2400,
|
||||
height: 1600,
|
||||
contentType: 'image/webp',
|
||||
usedIn: 3,
|
||||
uploadedBy: 'Inès Duarte',
|
||||
createdAt: '2026-09-08T12:00:00Z',
|
||||
duplicate: false,
|
||||
tone: 1,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
filename: 'kitchen-before.webp',
|
||||
size: 1153434,
|
||||
width: 2400,
|
||||
height: 1600,
|
||||
contentType: 'image/webp',
|
||||
usedIn: 1,
|
||||
uploadedBy: 'Inès Duarte',
|
||||
createdAt: '2026-09-08T11:00:00Z',
|
||||
duplicate: false,
|
||||
tone: 2,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
filename: 'logo-renew.svg',
|
||||
size: 8192,
|
||||
width: null,
|
||||
height: null,
|
||||
contentType: 'image/svg+xml',
|
||||
usedIn: 5,
|
||||
uploadedBy: 'jonas',
|
||||
createdAt: '2026-09-05T12:00:00Z',
|
||||
duplicate: false,
|
||||
tone: 3,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
filename: 'floorboards-copy.jpg',
|
||||
size: 2516582,
|
||||
width: 3600,
|
||||
height: 2400,
|
||||
contentType: 'image/jpeg',
|
||||
usedIn: 3,
|
||||
uploadedBy: 'Marta Alves',
|
||||
createdAt: '2026-09-02T12:00:00Z',
|
||||
duplicate: true,
|
||||
tone: 0,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
filename: 'IMG_2208.jpg',
|
||||
size: 2516582,
|
||||
width: 3600,
|
||||
height: 2400,
|
||||
contentType: 'image/jpeg',
|
||||
usedIn: 2,
|
||||
uploadedBy: 'jonas',
|
||||
createdAt: '2026-08-30T12:00:00Z',
|
||||
duplicate: true,
|
||||
tone: 0,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
filename: 'IMG_4471.jpg',
|
||||
size: 3250585,
|
||||
width: 3600,
|
||||
height: 2400,
|
||||
contentType: 'image/jpeg',
|
||||
usedIn: 2,
|
||||
uploadedBy: 'Marta Alves',
|
||||
createdAt: '2026-08-29T12:00:00Z',
|
||||
duplicate: true,
|
||||
tone: 2,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
filename: 'window-frame.jpg',
|
||||
size: 1887437,
|
||||
width: 3000,
|
||||
height: 2000,
|
||||
contentType: 'image/jpeg',
|
||||
usedIn: 0,
|
||||
uploadedBy: 'Inès Duarte',
|
||||
createdAt: '2026-08-28T12:00:00Z',
|
||||
duplicate: false,
|
||||
tone: 3,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
filename: 'stone-wall-detail.jpg',
|
||||
size: 3250585,
|
||||
width: 3600,
|
||||
height: 2400,
|
||||
contentType: 'image/jpeg',
|
||||
usedIn: 1,
|
||||
uploadedBy: 'jonas',
|
||||
createdAt: '2026-08-21T12:00:00Z',
|
||||
duplicate: true,
|
||||
tone: 2,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
filename: 'bathroom-tiles.png',
|
||||
size: 880640,
|
||||
width: 1800,
|
||||
height: 1200,
|
||||
contentType: 'image/png',
|
||||
usedIn: 0,
|
||||
uploadedBy: 'Marta Alves',
|
||||
createdAt: '2026-08-04T12:00:00Z',
|
||||
duplicate: false,
|
||||
tone: 1,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
filename: 'lime-plaster.jpg',
|
||||
size: 2202009,
|
||||
width: 3200,
|
||||
height: 2133,
|
||||
contentType: 'image/jpeg',
|
||||
usedIn: 2,
|
||||
uploadedBy: 'Inès Duarte',
|
||||
createdAt: '2026-07-19T12:00:00Z',
|
||||
duplicate: false,
|
||||
tone: 1,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
filename: 'floorboards.jpg',
|
||||
size: 2516582,
|
||||
width: 3600,
|
||||
height: 2400,
|
||||
contentType: 'image/jpeg',
|
||||
usedIn: 4,
|
||||
uploadedBy: 'Inès Duarte',
|
||||
createdAt: '2026-06-14T12:00:00Z',
|
||||
duplicate: true,
|
||||
tone: 0,
|
||||
},
|
||||
]
|
||||
|
||||
const mediaOrigin = 'https://cms.renew.example'
|
||||
const mediaSession: ApiMock = {
|
||||
status: 200,
|
||||
body: { ...(verifiedSession.body as object), display_name: 'Marta Alves' },
|
||||
}
|
||||
const mediaFullCounts = { all: 12, unused: 2, duplicates: 2 }
|
||||
const mediaNorthWallUsage = [
|
||||
{ id: 10, title: 'How we price a bathroom renovation', published: true },
|
||||
{ id: 11, title: 'Winter checklist for timber windows', published: false },
|
||||
]
|
||||
const mediaFloorboardsUsage = [
|
||||
{ id: 10, title: 'How we price a bathroom renovation', published: true },
|
||||
{ id: 11, title: 'Winter checklist for timber windows', published: false },
|
||||
{ id: 12, title: 'Before and after: the Rue Verte kitchen', published: true },
|
||||
{ id: 13, title: 'Why we reuse original floorboards', published: true },
|
||||
]
|
||||
const mediaDuplicateIds = [12, 5, 6]
|
||||
|
||||
function mediaItem(sample: MediaSample, filename = sample.filename) {
|
||||
return {
|
||||
id: sample.id,
|
||||
filename,
|
||||
url: `/media/${filename}`,
|
||||
size: sample.size,
|
||||
content_type: sample.contentType,
|
||||
width: sample.width,
|
||||
height: sample.height,
|
||||
sha256: `${sample.tone}${String(sample.id).padStart(2, '0').repeat(31)}a`,
|
||||
uploaded_by: sample.uploadedBy,
|
||||
created_at: sample.createdAt,
|
||||
used_in: sample.usedIn,
|
||||
duplicate: sample.duplicate,
|
||||
}
|
||||
}
|
||||
|
||||
function mediaItems(ids?: number[], without: number[] = []) {
|
||||
return mediaSamples
|
||||
.filter((sample) => (!ids || ids.includes(sample.id)) && !without.includes(sample.id))
|
||||
.map((sample) => mediaItem(sample))
|
||||
}
|
||||
|
||||
function mediaList(items: unknown[], counts = mediaFullCounts) {
|
||||
return { items, next_cursor: null, counts }
|
||||
}
|
||||
|
||||
function mediaApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': mediaSession,
|
||||
'GET /v1/media/': {
|
||||
status: 200,
|
||||
bodyFor: (url) =>
|
||||
url.searchParams.has('hash')
|
||||
? mediaList(mediaItems(mediaDuplicateIds))
|
||||
: mediaList(mediaItems()),
|
||||
},
|
||||
'GET /v1/media/1/usage': { status: 200, body: mediaNorthWallUsage },
|
||||
'GET /v1/media/12/usage': { status: 200, body: mediaFloorboardsUsage },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mediaApiAfterDelete(id: number, overrides: Record<string, ApiMock> = {}) {
|
||||
return mediaApi({
|
||||
'GET /v1/media/': {
|
||||
status: 200,
|
||||
bodyFor: (_url, call) =>
|
||||
call === 1
|
||||
? mediaList(mediaItems())
|
||||
: mediaList(mediaItems(undefined, [id]), { all: 11, unused: 1, duplicates: 2 }),
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function mediaState(
|
||||
api: Record<string, ApiMock>,
|
||||
steps?: (page: Page) => Promise<void>,
|
||||
path = '/media',
|
||||
): VisualState {
|
||||
return { path, origin: mediaOrigin, api, steps }
|
||||
}
|
||||
|
||||
const mediaPending: VisualState = { path: '/media', skip: 'pending' }
|
||||
|
||||
function mediaCard(page: Page, id: number) {
|
||||
return page.locator(`[data-media-card][data-media-id="${id}"]`)
|
||||
}
|
||||
|
||||
async function openMediaPanel(page: Page, id: number) {
|
||||
await mediaCard(page, id).dispatchEvent('click')
|
||||
await page.getByRole('dialog').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
async function openMediaDialog(page: Page, id: number, action: 'Rename' | 'Delete') {
|
||||
await openMediaPanel(page, id)
|
||||
await page.getByRole('dialog').getByRole('button', { name: action, exact: true }).click()
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
function mediaRenameWith(value: string, submit: boolean) {
|
||||
return async (page: Page) => {
|
||||
await openMediaDialog(page, 1, 'Rename')
|
||||
const field = page.getByRole('alertdialog').locator('input')
|
||||
await field.fill(value)
|
||||
if (submit) {
|
||||
await page
|
||||
.getByRole('alertdialog')
|
||||
.getByRole('button', { name: 'Rename', exact: true })
|
||||
.click()
|
||||
} else {
|
||||
await field.focus()
|
||||
}
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function mediaConfirmDelete(id: number) {
|
||||
return async (page: Page) => {
|
||||
await openMediaDialog(page, id, 'Delete')
|
||||
await page
|
||||
.getByRole('alertdialog')
|
||||
.getByRole('button', { name: 'Delete image', exact: true })
|
||||
.click()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
async function goOffline(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { get: () => false, configurable: true })
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
})
|
||||
await page.getByText("You're offline.").first().waitFor()
|
||||
}
|
||||
|
||||
const mediaStates: Record<string, VisualState> = {
|
||||
'Grid, many images': mediaState(mediaApi()),
|
||||
'Grid, few images': mediaState(
|
||||
mediaApi({
|
||||
'GET /v1/media/': {
|
||||
status: 200,
|
||||
body: mediaList(mediaItems([1, 4, 8]), { all: 3, unused: 1, duplicates: 0 }),
|
||||
},
|
||||
}),
|
||||
),
|
||||
'List view': mediaState(mediaApi(), undefined, '/media?view=list'),
|
||||
Loading: mediaState(mediaApi({ 'GET /v1/media/': { status: 200, hang: true } })),
|
||||
'Empty library, first run': mediaState(
|
||||
mediaApi({
|
||||
'GET /v1/media/': {
|
||||
status: 200,
|
||||
body: mediaList([], { all: 0, unused: 0, duplicates: 0 }),
|
||||
},
|
||||
}),
|
||||
),
|
||||
'No results': mediaState(
|
||||
mediaApi({ 'GET /v1/media/': { status: 200, body: mediaList([]) } }),
|
||||
undefined,
|
||||
'/media?q=wall&filter=unused',
|
||||
),
|
||||
'Hover, focus and keyboard': mediaState(mediaApi(), async (page) => {
|
||||
await mediaCard(page, 3).focus()
|
||||
await mediaCard(page, 2).hover()
|
||||
}),
|
||||
'Selection and bulk bar': mediaPending,
|
||||
'Bulk delete: confirm': mediaPending,
|
||||
'Bulk delete: in progress': mediaPending,
|
||||
'Bulk delete: partly failed': mediaPending,
|
||||
'Bulk delete: done': mediaPending,
|
||||
'Detail panel: in use': mediaState(mediaApi(), (page) => openMediaPanel(page, 1)),
|
||||
'Detail panel: unused': mediaState(mediaApi(), (page) => openMediaPanel(page, 8)),
|
||||
'Detail panel: duplicate': mediaState(mediaApi(), (page) => openMediaPanel(page, 12)),
|
||||
'Copy URL': mediaState(mediaApi(), async (page) => {
|
||||
await openMediaPanel(page, 1)
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Copy URL', exact: true })
|
||||
.click()
|
||||
await page.getByText('URL copied', { exact: true }).first().waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}),
|
||||
'Rename: dialog': mediaState(mediaApi(), mediaRenameWith('north-wall', false)),
|
||||
'Rename: saving': mediaState(
|
||||
mediaApi({ 'PATCH /v1/media/1': { status: 200, hang: true } }),
|
||||
mediaRenameWith('north-wall-01', true),
|
||||
),
|
||||
'Rename: validation': mediaState(mediaApi(), async (page) => {
|
||||
await mediaRenameWith('!!!', false)(page)
|
||||
await page.getByRole('alertdialog').locator('input').blur()
|
||||
await page.mouse.move(0, 0)
|
||||
}),
|
||||
'Rename: failed': mediaState(
|
||||
mediaApi({ 'PATCH /v1/media/1': { status: 500 } }),
|
||||
mediaRenameWith('north-wall-01', true),
|
||||
),
|
||||
'Rename: done': mediaState(
|
||||
mediaApi({
|
||||
'PATCH /v1/media/1': {
|
||||
status: 200,
|
||||
body: mediaItem(mediaSamples[0]!, 'north-wall-01.jpg'),
|
||||
},
|
||||
}),
|
||||
async (page) => {
|
||||
await mediaRenameWith('north-wall-01', true)(page)
|
||||
await page.getByText('Image renamed', { exact: true }).first().waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
),
|
||||
'Delete: unused image': mediaState(mediaApi(), (page) => openMediaDialog(page, 8, 'Delete')),
|
||||
'Delete: image in use': mediaState(mediaApi(), (page) => openMediaDialog(page, 1, 'Delete')),
|
||||
'Delete: in progress': mediaState(
|
||||
mediaApi({ 'DELETE /v1/media/1': { status: 204, hang: true } }),
|
||||
mediaConfirmDelete(1),
|
||||
),
|
||||
'Delete: failed': mediaState(
|
||||
mediaApi({ 'DELETE /v1/media/8': { status: 500 } }),
|
||||
mediaConfirmDelete(8),
|
||||
),
|
||||
'Delete: done': mediaState(
|
||||
mediaApiAfterDelete(8, { 'DELETE /v1/media/8': { status: 204 } }),
|
||||
async (page) => {
|
||||
await mediaConfirmDelete(8)(page)
|
||||
await page.getByText('Image deleted', { exact: true }).first().waitFor()
|
||||
await scrollToTop(page)
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
),
|
||||
'Image already gone (404)': mediaState(
|
||||
mediaApiAfterDelete(8, { 'DELETE /v1/media/8': { status: 404 } }),
|
||||
async (page) => {
|
||||
await mediaConfirmDelete(8)(page)
|
||||
await page.getByText('That image no longer exists', { exact: true }).first().waitFor()
|
||||
await scrollToTop(page)
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
),
|
||||
'Upload: drag over the page': mediaPending,
|
||||
'Upload: several files, mixed': mediaPending,
|
||||
'Upload: summary': mediaPending,
|
||||
'Upload: rejected files (413, 400)': mediaPending,
|
||||
'Upload: too many requests (429)': mediaPending,
|
||||
'Duplicates: groups': mediaPending,
|
||||
'Duplicates: empty': mediaPending,
|
||||
'Merge: confirm': mediaPending,
|
||||
'Merge: in progress': mediaPending,
|
||||
'Merge: failed': mediaPending,
|
||||
'Merge: conflict (409)': mediaPending,
|
||||
'Merge: done': mediaPending,
|
||||
'List failed to load': mediaState(mediaApi({ 'GET /v1/media/': { status: 500 } })),
|
||||
Offline: mediaState(mediaApi(), goOffline),
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
profile: {
|
||||
'Account menu': {
|
||||
@@ -1735,4 +2163,5 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
steps: submitAcceptInvite('lea', 'limewash2026'),
|
||||
},
|
||||
},
|
||||
media: mediaStates,
|
||||
}
|
||||
Reference in new issue
Block a user