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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user