feat: media picker and image removal
This commit is contained in:
1 parent
008b0dfbb9
commit
b3793d5dbb
54 files changed
+3769
-1412
No files matched your search
+273
-5
@@ -186,10 +186,17 @@ const editorArticle = {
|
||||
updated_at: editorNow,
|
||||
}
|
||||
|
||||
const emptyLibrary = {
|
||||
items: [],
|
||||
next_cursor: null,
|
||||
counts: { all: 0, unused: 0, duplicates: 0 },
|
||||
}
|
||||
|
||||
function editorApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/article/12': { status: 200, body: editorArticle },
|
||||
'GET /v1/media/': { status: 200, body: emptyLibrary },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -384,7 +391,7 @@ async function freezeUploadProgress(page: Page, loaded: number, total: number) {
|
||||
const originalSend = proto.send as (...args: unknown[]) => void
|
||||
proto.send = function (this: XMLHttpRequest, ...args: unknown[]) {
|
||||
const url = (this as unknown as { __imageUrl?: string }).__imageUrl ?? ''
|
||||
if (url.includes('/image')) {
|
||||
if (url.includes('/v1/media')) {
|
||||
setTimeout(() => {
|
||||
const event = new Event('progress')
|
||||
Object.assign(event, { loaded, total, lengthComputable: true })
|
||||
@@ -1583,6 +1590,253 @@ const mediaStates: Record<string, VisualState> = {
|
||||
Offline: mediaState(mediaApi(), goOffline),
|
||||
}
|
||||
|
||||
const pickerIntro =
|
||||
'A bathroom is priced by what has to happen behind the walls before anything gets tiled.'
|
||||
const pickerOutro = 'We don’t add a margin on materials you choose yourself.'
|
||||
const invisibleImage = '/static/uploadedImages/blank.svg'
|
||||
const invisibleImageSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'
|
||||
const boardImageSvgFor = (width: number, height: number) =>
|
||||
[
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 672 378" preserveAspectRatio="xMidYMid slice">`,
|
||||
'<style>.tone{fill:#e8dfd1}.ink{color:#7d6d56}@media (prefers-color-scheme:dark){.tone{fill:#d4c5b0}}</style>',
|
||||
'<defs><pattern id="hatch" width="12" height="12" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">',
|
||||
'<rect width="1" height="12" fill="rgb(128 128 128 / 0.12)"/></pattern></defs>',
|
||||
'<rect class="tone" width="672" height="378"/>',
|
||||
'<rect fill="url(#hatch)" width="672" height="378"/>',
|
||||
'<g class="ink" opacity="0.55" transform="translate(316 160) scale(1.6667)" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">',
|
||||
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></g>',
|
||||
'</svg>',
|
||||
].join('')
|
||||
|
||||
const boardImageMaxWidth = 672
|
||||
const boardImageSideSpace = 32
|
||||
|
||||
function boardImageFor(page: Page) {
|
||||
const viewport = page.viewportSize()
|
||||
const width = Math.min(
|
||||
boardImageMaxWidth,
|
||||
(viewport?.width ?? boardImageMaxWidth) - boardImageSideSpace,
|
||||
)
|
||||
return boardImageSvgFor(width, Math.round((width * 9) / 16))
|
||||
}
|
||||
const boardImage = '/static/uploadedImages/board-image.svg'
|
||||
|
||||
function pickerBody(image: string | null, ids: number[] = []) {
|
||||
const nodes = ids.map(
|
||||
(id) => `<img src="${image ?? invisibleImage}" alt="" data-image-id="${id}">`,
|
||||
)
|
||||
return `<p>${pickerIntro}</p>${nodes.join('')}<p>${pickerOutro}</p>`
|
||||
}
|
||||
|
||||
const pickerCounts = { all: 48, unused: 2, duplicates: 2 }
|
||||
const pickerFirstPageCursor = 'c24'
|
||||
const pickerSearchTerm = 'wall'
|
||||
|
||||
function pickerList(url: URL) {
|
||||
if (url.searchParams.has('hash')) {
|
||||
return mediaList(mediaItems([1]), pickerCounts)
|
||||
}
|
||||
if (url.searchParams.has('q')) {
|
||||
return { items: [], next_cursor: null, counts: pickerCounts }
|
||||
}
|
||||
return { items: mediaItems(), next_cursor: pickerFirstPageCursor, counts: pickerCounts }
|
||||
}
|
||||
|
||||
function pickerApi(
|
||||
overrides: Record<string, ApiMock> = {},
|
||||
body = pickerBody(null, [1, 4]),
|
||||
): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': mediaSession,
|
||||
'GET /v1/article/12': { status: 200, body: { ...editorArticle, body } },
|
||||
'GET /v1/media/': { status: 200, bodyFor: pickerList },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function pickerState(
|
||||
steps?: (page: Page) => Promise<void>,
|
||||
api: Record<string, ApiMock> = pickerApi(),
|
||||
): VisualState {
|
||||
return {
|
||||
path: '/article/12/edit',
|
||||
api,
|
||||
steps: async (page) => {
|
||||
await stubEditorImages(page)
|
||||
await steps?.(page)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function openPickerTab(page: Page, name: 'Upload' | 'Library') {
|
||||
await openImageUploader(page)
|
||||
await page.getByRole('tab', { name, exact: true }).click()
|
||||
}
|
||||
|
||||
async function waitForPickerTiles(page: Page) {
|
||||
await page.locator('[data-picker-tile]').first().waitFor()
|
||||
}
|
||||
|
||||
async function selectPickerTile(page: Page, filename: string) {
|
||||
await waitForPickerTiles(page)
|
||||
await page.locator(`[data-picker-tile][aria-label^="${filename},"]`).click()
|
||||
await page.getByTestId('picker-panel').waitFor()
|
||||
}
|
||||
|
||||
async function insertFromLibrary(page: Page, filename: string) {
|
||||
await openImageUploader(page)
|
||||
await selectPickerTile(page, filename)
|
||||
await page.getByRole('button', { name: 'Insert image', exact: true }).click()
|
||||
await page.getByText('Save to keep it in the article.').first().waitFor()
|
||||
}
|
||||
|
||||
async function selectEditorImage(page: Page) {
|
||||
await page.locator('[data-testid="article-editor-image"] img').first().click()
|
||||
await page.getByRole('button', { name: 'Remove image', exact: true }).waitFor()
|
||||
}
|
||||
|
||||
async function stubEditorImages(page: Page) {
|
||||
await page.route('**/static/uploadedImages/blank.svg', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: invisibleImageSvg }),
|
||||
)
|
||||
await page.route('**/static/uploadedImages/board-image.svg', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: boardImageFor(page) }),
|
||||
)
|
||||
await page.reload()
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('#app')?.textContent ?? '').trim().length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
async function stubBoardImage(page: Page) {
|
||||
await page.route('**/media/north-wall.jpg', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: boardImageFor(page) }),
|
||||
)
|
||||
}
|
||||
|
||||
async function browseLibraryThenClose(page: Page) {
|
||||
await openImageUploader(page)
|
||||
await waitForPickerTiles(page)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.getByRole('dialog').waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
async function requestRemoveFromEditor(page: Page) {
|
||||
await browseLibraryThenClose(page)
|
||||
await selectEditorImage(page)
|
||||
await page.getByRole('button', { name: 'Remove image', exact: true }).click()
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
}
|
||||
|
||||
const simplifiedEditorReason =
|
||||
'The board draws a simplified editor (centred 680 px column, no toolbar, no details panel, fixed image placeholder) while the real editor is left-aligned with a toolbar, a taller header and a details panel, so the image block and page chrome sit elsewhere'
|
||||
const simplifiedEditorMaxPercent = 40
|
||||
|
||||
function withSimplifiedEditorException(state: VisualState): VisualState {
|
||||
return {
|
||||
...state,
|
||||
exception: {
|
||||
maxPercent: simplifiedEditorMaxPercent,
|
||||
reason: simplifiedEditorReason,
|
||||
approvedBy: 'user',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const pickerStates: Record<string, VisualState> = {
|
||||
'Upload tab': pickerState(async (page) => {
|
||||
await openPickerTab(page, 'Upload')
|
||||
}),
|
||||
'Upload tab: already in your library': {
|
||||
...pickerState(async (page) => {
|
||||
await openPickerTab(page, 'Upload')
|
||||
await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9))
|
||||
await page.getByText('Already in your library').waitFor()
|
||||
}),
|
||||
exception: {
|
||||
maxPercent: 20,
|
||||
reason: 'The board buttons collapse to about 22 px on mobile (flex:1 in a column) while the feature requires 44 px full-width stacked buttons, which makes the modal 31 px taller',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
},
|
||||
'Library: loading': pickerState(
|
||||
async (page) => {
|
||||
await openImageUploader(page)
|
||||
await page.getByTestId('picker-skeleton-tile').first().waitFor()
|
||||
},
|
||||
pickerApi({ 'GET /v1/media/': { status: 200, hang: true } }),
|
||||
),
|
||||
'Library: nothing selected': pickerState(async (page) => {
|
||||
await openImageUploader(page)
|
||||
await waitForPickerTiles(page)
|
||||
}),
|
||||
'Library: selected': pickerState(async (page) => {
|
||||
await openImageUploader(page)
|
||||
await selectPickerTile(page, 'kitchen-after.webp')
|
||||
}),
|
||||
'Library: alt text entered': pickerState(async (page) => {
|
||||
await openImageUploader(page)
|
||||
await selectPickerTile(page, 'kitchen-after.webp')
|
||||
await page
|
||||
.getByPlaceholder('e.g. North wall of the kitchen before plastering')
|
||||
.fill('A kitchen with new oak worktops and white tiles')
|
||||
}),
|
||||
'Library: loading more': pickerState(async (page) => {
|
||||
await openImageUploader(page)
|
||||
await waitForPickerTiles(page)
|
||||
await page.route(/\/v1\/media\/\?.*cursor=/, () => undefined)
|
||||
await page.getByRole('button', { name: 'Load more', exact: true }).click()
|
||||
await page.getByTestId('picker-skeleton-tile').first().waitFor()
|
||||
}),
|
||||
'Library: empty': pickerState(
|
||||
async (page) => {
|
||||
await openPickerTab(page, 'Library')
|
||||
await page.getByText('Your library is empty').waitFor()
|
||||
},
|
||||
pickerApi({ 'GET /v1/media/': { status: 200, body: emptyLibrary } }),
|
||||
),
|
||||
'Library: no results': pickerState(async (page) => {
|
||||
await openImageUploader(page)
|
||||
await waitForPickerTiles(page)
|
||||
await page.getByLabel('Search by filename').fill(pickerSearchTerm)
|
||||
await page.getByText(`No images match "${pickerSearchTerm}"`).waitFor()
|
||||
}),
|
||||
'Library: failed to load': pickerState(
|
||||
async (page) => {
|
||||
await openImageUploader(page)
|
||||
await page.getByText("Couldn't load your library").waitFor()
|
||||
},
|
||||
pickerApi({ 'GET /v1/media/': { status: 500 } }),
|
||||
),
|
||||
Inserted: withSimplifiedEditorException(
|
||||
pickerState(
|
||||
async (page) => {
|
||||
await stubBoardImage(page)
|
||||
await page.getByText('behind the walls').click()
|
||||
await page.keyboard.press('End')
|
||||
await insertFromLibrary(page, 'north-wall.jpg')
|
||||
await page.waitForTimeout(400)
|
||||
await selectEditorImage(page)
|
||||
},
|
||||
pickerApi({}, pickerBody(null)),
|
||||
),
|
||||
),
|
||||
'Remove image: confirm': withSimplifiedEditorException(
|
||||
pickerState(requestRemoveFromEditor, pickerApi({}, pickerBody(boardImage, [1]))),
|
||||
),
|
||||
Removed: pickerState(
|
||||
async (page) => {
|
||||
await requestRemoveFromEditor(page)
|
||||
await page
|
||||
.getByRole('alertdialog')
|
||||
.getByRole('button', { name: 'Remove image', exact: true })
|
||||
.click()
|
||||
await page.getByText("It's still in your library.").first().waitFor()
|
||||
},
|
||||
pickerApi({}, pickerBody(boardImage, [1])),
|
||||
),
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
profile: {
|
||||
'Account menu': {
|
||||
@@ -2306,17 +2560,30 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: editorApi({
|
||||
'POST /v1/article/12/image': {
|
||||
'POST /v1/media/': {
|
||||
status: 201,
|
||||
body: { location: '/static/uploadedImages/north-wall.jpg' },
|
||||
body: {
|
||||
id: 13,
|
||||
filename: 'north-wall.jpg',
|
||||
url: '/static/uploadedImages/north-wall.jpg',
|
||||
size: mib(3.9),
|
||||
content_type: 'image/jpeg',
|
||||
width: 4032,
|
||||
height: 3024,
|
||||
sha256: 'a'.repeat(64),
|
||||
uploaded_by: 'Marta Alves',
|
||||
created_at: editorNow,
|
||||
used_in: 0,
|
||||
duplicate: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await mockUploadedImage(page)
|
||||
await page.getByText('add a margin on materials').click()
|
||||
await page.keyboard.press('Home')
|
||||
await openImageUploader(page)
|
||||
await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9))
|
||||
await openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Upload image' }).click()
|
||||
await page.getByText('Save to keep it in the article.').first().waitFor()
|
||||
await page.waitForTimeout(650)
|
||||
},
|
||||
@@ -2514,4 +2781,5 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
},
|
||||
media: mediaStates,
|
||||
'media-picker': pickerStates,
|
||||
}
|
||||
Reference in new issue
Block a user