feat: media picker and image removal

This commit is contained in:
fsylvain committed 2026-10-01 05:04:58 +02:00
1 parent 008b0dfbb9
commit b3793d5dbb
54 files changed
+3769 -1412

No files matched your search

+1 -11
View File
@@ -77,7 +77,6 @@ function sixArticleFixture(): Article[] {
id: 1,
title: 'How we price a bathroom renovation',
body: '',
images: [],
is_online: true,
created_at: '2026-03-12T09:14:00Z',
updated_at: iso(2 * HOUR_MS),
@@ -86,7 +85,6 @@ function sixArticleFixture(): Article[] {
id: 2,
title: 'Choosing lime plaster for old stone walls',
body: '',
images: [],
is_online: false,
created_at: '2026-09-09T09:00:00Z',
updated_at: iso(1 * DAY_MS),
@@ -95,7 +93,6 @@ function sixArticleFixture(): Article[] {
id: 3,
title: 'Before and after: the Rue Verte kitchen',
body: '',
images: [],
is_online: true,
created_at: '2026-08-28T09:00:00Z',
updated_at: iso(3 * DAY_MS),
@@ -103,12 +100,7 @@ function sixArticleFixture(): Article[] {
{
id: 4,
title: 'Winter checklist for timber windows',
body: '',
images: [
{ id: 10, url: '/i/10.png' },
{ id: 11, url: '/i/11.png' },
{ id: 12, url: '/i/12.png' },
],
body: '<img src="/i/10.png"><img src="/i/11.png"><img src="/i/12.png">',
is_online: false,
created_at: '2026-09-02T09:00:00Z',
updated_at: iso(5 * DAY_MS),
@@ -117,7 +109,6 @@ function sixArticleFixture(): Article[] {
id: 5,
title: 'Why we reuse original floorboards',
body: '',
images: [],
is_online: true,
created_at: '2026-06-14T09:00:00Z',
updated_at: '2026-08-18T09:00:00Z',
@@ -126,7 +117,6 @@ function sixArticleFixture(): Article[] {
id: 6,
title: 'Our first passive-house retrofit',
body: '',
images: [],
is_online: true,
created_at: '2026-04-03T09:00:00Z',
updated_at: '2026-07-02T09:00:00Z',
@@ -10,6 +10,8 @@ import RootComponent from '@/App.vue'
const FIXED_NOW = '2026-09-26T11:02:00Z'
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn(async () => 'a'.repeat(64)) }))
vi.mock('@/lib/imageCrop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
return {
@@ -118,7 +120,6 @@ function articleFixture(overrides: Partial<Article> = {}): Article {
id: 12,
title: 'How we price a bathroom renovation',
body: '<p>a</p>',
images: [],
is_online: false,
created_at: '2026-03-12T09:14:00Z',
updated_at: FIXED_NOW,
@@ -186,29 +187,39 @@ afterEach(async () => {
await router.push('/login')
})
describe("Opening the uploader from the toolbar's image button shows the idle state", () => {
it('opens ImageUploader idle and sends no request', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
const EMPTY_LIBRARY = { items: [], next_cursor: null, counts: { all: 0, unused: 0, duplicates: 0 } }
function articleHandlers(extra: Record<string, () => Response | Promise<Response>> = {}) {
return {
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'GET /v1/media/?limit=24': () => mockResponse(200, EMPTY_LIBRARY),
...extra,
}
}
describe("Opening the uploader from the toolbar's image button on an empty library shows Upload", () => {
it('opens ImageUploader on the idle Upload tab and sends no POST', async () => {
const wrapper = await mountEditor(articleHandlers())
await toolbarImageButton(wrapper)?.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('Add an image')
expect(document.body.textContent).toContain('Drop an image here')
expect(document.body.textContent).toContain('Drop an image here, or choose a file')
expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
expect(findButton('Choose file')).toBeTruthy()
expect(findButton('Cancel')).toBeTruthy()
expect(findButton('Insert image')).toBeUndefined()
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
})
})
describe('Dropping a valid file onto the editor opens the uploader already showing the crop step', () => {
it('opens the crop step, and confirming it sends the resized file', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
describe('Dropping a valid file onto the editor opens the uploader on the crop step', () => {
it('looks the hash up, skips the list, and uploads to /v1/media/ from the crop step', async () => {
const wrapper = await mountEditor(
articleHandlers({
[`GET /v1/media/?hash=${'a'.repeat(64)}`]: () => mockResponse(200, EMPTY_LIBRARY),
}),
)
const file = fileFixture('before.png', 'image/png', 3.9)
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
@@ -219,22 +230,22 @@ describe('Dropping a valid file onto the editor opens the uploader already showi
await flushPromises()
expect(document.body.textContent).toContain('Crop and resize')
expect(document.body.textContent).not.toContain('Drop an image here')
expect(vi.mocked(global.fetch).mock.calls.map(([url]) => url)).not.toContain(
'/v1/media/?limit=24',
)
findButton('Upload image')?.click()
await flushPromises()
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
expect(latestXHR().url).toBe('/v1/article/12/image')
expect(latestXHR().url).toBe('/v1/media/')
expect((latestXHR().sentBody?.get('file') as File).name).toBe('before.png')
})
})
describe('A successful upload inserts the image at the cursor and marks the draft dirty', () => {
it('inserts the image, marks the draft dirty and toasts', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
it('inserts the image with its id, marks the draft dirty and toasts', async () => {
const wrapper = await mountEditor(articleHandlers())
await toolbarImageButton(wrapper)?.trigger('click')
await flushPromises()
@@ -247,20 +258,25 @@ describe('A successful upload inserts the image at the cursor and marks the draf
})
fileInput.dispatchEvent(new Event('change'))
await flushPromises()
findButton('Upload image')?.click()
await flushPromises()
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
latestXHR().resolve(
201,
JSON.stringify({ location: '/static/uploadedImages/north-wall.jpg' }),
JSON.stringify({
id: 13,
filename: 'north-wall.jpg',
url: '/static/uploadedImages/13.jpg',
size: 4089446,
}),
)
await flushPromises()
const insertedBody = wrapper.findComponent(ArticleEditor).props('modelValue')
expect(insertedBody).toContain('/static/uploadedImages/north-wall.jpg')
expect(insertedBody).toContain('<img src="/static/uploadedImages/north-wall.jpg" alt="">')
expect(insertedBody).toContain(
'<img src="/static/uploadedImages/13.jpg" alt="" data-image-id="13">',
)
expect(wrapper.text()).toContain('Unsaved changes')
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
expect(toastAdd).toHaveBeenCalledWith({
@@ -0,0 +1,191 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
import { fixture } from './mediaTestKit'
const FIXED_NOW = '2026-09-26T11:02:00Z'
const BODY_WITH_TWO_IMAGES =
'<p>Intro</p><img src="/media/north-wall.jpg" alt="" data-image-id="1"><img src="/media/logo-renew.svg" alt="" data-image-id="4">'
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
function articleFixture(body = BODY_WITH_TWO_IMAGES): Article {
return {
id: 12,
title: 'How we price a bathroom renovation',
body,
is_online: false,
created_at: '2026-03-12T09:14:00Z',
updated_at: FIXED_NOW,
}
}
function libraryPage() {
const items = fixture().map((item) =>
item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : item,
)
return { items, next_cursor: 'c24', counts: { all: 48, unused: 2, duplicates: 2 } }
}
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', () => ({
useOverlay: () => ({ create: vi.fn() }),
useToast: () => ({ add: toastAdd }),
}))
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountEditor(body = BODY_WITH_TWO_IMAGES) {
setActivePinia(createPinia())
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = vi.fn(async (url: string) => {
if (url === '/v1/article/12') {
return mockResponse(200, articleFixture(body))
}
if (url === '/v1/media/?limit=24') {
return mockResponse(200, libraryPage())
}
throw new Error(`Unexpected fetch call: ${url}`)
}) as unknown as typeof fetch
await router.push('/article/12/edit')
const wrapper = mount(RootComponent, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
function findButton(text: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
async function openPicker(wrapper: Awaited<ReturnType<typeof mount>>) {
await wrapper
.findAll('button')
.find((button) => button.text() === 'Image')
?.trigger('click')
await flushPromises()
}
function tile(filename: string) {
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]')).find(
(element) => element.getAttribute('aria-label')?.startsWith(`${filename},`),
)!
}
function taggedIds() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-picker-tile]'))
.filter((element) => element.textContent?.includes('In this article'))
.map((element) => element.getAttribute('data-image-id'))
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
document.body.innerHTML = ''
await router.push('/login')
})
describe('An image is in this article when its node is in the current content', () => {
it('tags images 1 and 4 and no other when the dialog opens', async () => {
const wrapper = await mountEditor()
await openPicker(wrapper)
expect(taggedIds()).toEqual(['1', '4'])
})
it('counts an insertion that has not been saved yet', async () => {
const wrapper = await mountEditor('<p>Intro</p>')
wrapper.findComponent(ArticleEditor).vm.insertImage('/static/uploadedImages/2.webp', '', 2)
await flushPromises()
await openPicker(wrapper)
expect(taggedIds()).toEqual(['2'])
})
it('no longer tags an image that was removed from the body', async () => {
const wrapper = await mountEditor()
wrapper.findComponent(ArticleEditor).vm.removeImage(1)
await flushPromises()
await openPicker(wrapper)
expect(taggedIds()).toEqual(['4'])
})
})
describe('Insert puts the chosen image in the body with its alt text', () => {
it('inserts it at the cursor, marks the draft dirty, toasts and closes the dialog', async () => {
const wrapper = await mountEditor('<p>Intro</p>')
await openPicker(wrapper)
tile('kitchen-after.webp').click()
await flushPromises()
const alt = document.body.querySelector<HTMLInputElement>(
'input[placeholder="e.g. North wall of the kitchen before plastering"]',
)!
alt.value = 'A kitchen with new oak worktops and white tiles'
alt.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
findButton('Insert image')?.click()
await flushPromises()
const body = wrapper.findComponent(ArticleEditor).props('modelValue')
expect(body).toContain(
'<img src="/static/uploadedImages/2.webp" alt="A kitchen with new oak worktops and white tiles" data-image-id="2">',
)
expect(wrapper.text()).toContain('Unsaved changes')
expect(
(wrapper.find('[data-testid="save-button"]').element as HTMLButtonElement).disabled,
).toBe(false)
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image added',
description: 'Save to keep it in the article.',
})
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
const methods = vi
.mocked(global.fetch)
.mock.calls.map(([, init]) => (init as RequestInit | undefined)?.method)
expect(methods.every((method) => method === undefined)).toBe(true)
})
it('leaves the content and the draft alone when the dialog is cancelled', async () => {
const wrapper = await mountEditor('<p>Intro</p>')
await openPicker(wrapper)
tile('kitchen-after.webp').click()
await flushPromises()
findButton('Cancel')?.click()
await flushPromises()
expect(wrapper.findComponent(ArticleEditor).props('modelValue')).not.toContain('<img')
expect(wrapper.text()).not.toContain('Unsaved changes')
expect(toastAdd).not.toHaveBeenCalled()
})
})
+66 -57
View File
@@ -3,6 +3,7 @@ import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSeenImagesStore } from '@/stores/seenImages'
import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
@@ -615,8 +616,13 @@ async function requestRemoveImage(wrapper: Awaited<ReturnType<typeof mount>>, im
await flushPromises()
}
describe('Requesting removal of an inline image opens the confirm dialog', () => {
it('opens ConfirmDialog with the exact copy', async () => {
function requestCalls(wrapper: Awaited<ReturnType<typeof mount>>) {
void wrapper
return vi.mocked(global.fetch).mock.calls.map(([url, init]) => routeFor(url as string, init))
}
describe('Requesting removal of an inline image opens the neutral confirm dialog', () => {
it('opens ConfirmDialog with the exact copy and sends nothing', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
})
@@ -624,27 +630,76 @@ describe('Requesting removal of an inline image opens the confirm dialog', () =>
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.title).toBe('Remove this image?')
expect(props.title).toBe('Remove this image from the article?')
expect(props.description).toBe(
"This image will be removed from the article. This can't be undone.",
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
)
expect(props.confirmLabel).toBe('Remove image')
expect(props.cancelLabel).toBe('Cancel')
expect(props.pendingLabel).toBe('Removing…')
expect(props.tone).toBe('danger')
expect(props.tone).toBe('neutral')
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
})
it('previews the image the session has seen with its filename and size', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
})
useSeenImagesStore().remember([{ id: 10, filename: 'before.png', size: 4089446 }])
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { preview: Record<string, string> }
expect(props.preview).toEqual({
src: '/static/uploadedImages/before.png',
filename: 'before.png',
size: '3.9 MB',
})
})
it('previews only the thumbnail for an image the session has not seen', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { preview: Record<string, string> }
expect(props.preview).toEqual({
src: '/static/uploadedImages/before.png',
filename: undefined,
size: undefined,
})
})
})
describe('Confirming removes the image, marks the draft dirty, and shows a success toast', () => {
it('removes the image from the content and reads Unsaved changes', async () => {
describe('Cancelling the confirm changes nothing', () => {
it('keeps the image and the draft state when the dialog resolves to cancel', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
})
await requestRemoveImage(wrapper)
overlayMock.resolveOpen(false)
await flushPromises()
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
expect(wrapper.text()).not.toContain('Unsaved changes')
expect(toastAdd).not.toHaveBeenCalled()
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
})
})
describe('Confirming removes the image from the body only, marks the draft dirty and toasts', () => {
it('removes the image from the content, reads Unsaved changes and sends no request', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'DELETE /v1/article/12/image/10': () => mockResponse(204),
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
await flushPromises()
expect(articleEditorBody(wrapper)).not.toContain('data-image-id="10"')
expect(wrapper.text()).toContain('Unsaved changes')
@@ -653,55 +708,9 @@ describe('Confirming removes the image, marks the draft dirty, and shows a succe
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image removed',
description: 'Save to keep this change.',
})
})
})
describe('A failed removal keeps the image and shows the dialog inline error', () => {
it.each([
{ name: 'a 404 response', handler: () => mockResponse(404) },
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('rejects with the inline error message for $name', async ({ handler }) => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'DELETE /v1/article/12/image/10': handler,
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { action: () => Promise<void> }
await expect(props.action()).rejects.toThrow(
"Couldn't remove the image, so it's still there. Try again in a moment.",
)
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
expect(wrapper.text()).not.toContain('Unsaved changes')
})
})
describe('Confirming a removal started by Backspace behaves exactly like confirming one started by the button', () => {
it('removes the image, marks the draft dirty, and shows the same success toast', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'DELETE /v1/article/12/image/10': () => mockResponse(204),
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(articleEditorBody(wrapper)).not.toContain('data-image-id="10"')
expect(wrapper.text()).toContain('Unsaved changes')
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image removed',
description: 'Save to keep this change.',
description: "It's still in your library.",
})
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
})
})