feat: editor image removal QOLs

This commit is contained in:
fsylvain committed 2026-09-29 02:44:31 +02:00
1 parent 0fc00ceeff
commit b29d0ed045
23 files changed
+1398 -151

No files matched your search

+151 -131
View File
@@ -10,11 +10,7 @@ import RootComponent from '@/App.vue'
const FIXED_NOW = '2026-09-26T11:02:00Z'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -28,9 +24,7 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -61,17 +55,15 @@ function articleFixture(overrides: Partial<Article> = {}): Article {
function createOverlayMock() {
let props: Record<string, unknown> | null = null
let resolveOpen: ((value: unknown) => void) | null = null
const create = vi.fn(
(_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
},
)
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
})
return {
create,
getProps: () => props,
@@ -120,13 +112,8 @@ function articleEditorBody(wrapper: Awaited<ReturnType<typeof mount>>) {
return wrapper.findComponent(ArticleEditor).props('modelValue')
}
async function typeBody(
wrapper: Awaited<ReturnType<typeof mount>>,
body: string,
) {
await wrapper
.findComponent(ArticleEditor)
.vm.$emit('update:modelValue', body)
async function typeBody(wrapper: Awaited<ReturnType<typeof mount>>, body: string) {
await wrapper.findComponent(ArticleEditor).vm.$emit('update:modelValue', body)
await flushPromises()
}
@@ -161,49 +148,35 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
new Promise((resolve) => {
resolveGet = () =>
resolve(mockResponse(200, articleFixture()))
resolveGet = () => resolve(mockResponse(200, articleFixture()))
}),
})
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false)
await vi.advanceTimersByTimeAsync(150)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
true,
)
expect(wrapper.find('[data-testid="skeleton-content"]').exists()).toBe(
true,
)
expect(wrapper.find('[data-testid="skeleton-details"]').exists()).toBe(
true,
)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="skeleton-content"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="skeleton-details"]').exists()).toBe(true)
resolveGet?.()
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false)
expect(wrapper.text()).toContain('How we price a bathroom renovation')
})
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
Promise.resolve(mockResponse(200, articleFixture())),
'GET /v1/article/12': () => Promise.resolve(mockResponse(200, articleFixture())),
})
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false)
expect(wrapper.text()).toContain('How we price a bathroom renovation')
})
})
@@ -216,19 +189,14 @@ describe('Saved — a freshly loaded article, draft or online', () => {
'reads the Saved state for is_online $is_online',
async ({ is_online, badge, publishLabel }) => {
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
mockResponse(200, articleFixture({ is_online })),
'GET /v1/article/12': () => mockResponse(200, articleFixture({ is_online })),
})
expect(wrapper.text()).toContain('Articles')
expect(wrapper.text()).toContain(
'How we price a bathroom renovation',
)
expect(wrapper.text()).toContain('How we price a bathroom renovation')
expect(wrapper.text()).toContain(badge)
expect(wrapper.text()).toContain('Saved at 11:02')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
expect(wrapper.text()).toContain(publishLabel)
expect(wrapper.text()).toContain('4 318 / 10 000')
},
@@ -244,9 +212,7 @@ describe('Unsaved changes — any edit marks the article dirty', () => {
await typeBody(wrapper, paragraphOfLength(4320))
expect(wrapper.text()).toContain('Unsaved changes')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
expect(wrapper.text()).toContain('Save and publish')
})
})
@@ -274,9 +240,7 @@ describe('Saving — Save sends the PATCH and disables itself while in flight',
body: paragraphOfLength(4320),
})
expect(wrapper.text()).toContain('Saving…')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
})
})
@@ -296,9 +260,7 @@ describe('Ctrl/⌘ S saves the same way as clicking Save', () => {
throw new Error(`Unexpected fetch call: ${routeFor(url, init)}`)
}) as unknown as typeof fetch
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 's', ctrlKey: true }),
)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }))
await flushPromises()
expect(JSON.parse(patchBody ?? '{}')).toEqual({
@@ -335,9 +297,7 @@ describe('A successful save returns to the Saved state with the new time', () =>
await flushPromises()
expect(wrapper.text()).toContain('Saved at 11:30')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
})
})
@@ -374,14 +334,8 @@ describe('Save failed — a 500 or a network failure keeps the changes and offer
await flushPromises()
expect(wrapper.text()).toContain('Save failed')
expect(
wrapper
.findAll('button')
.some((button) => button.text() === 'Retry'),
).toBe(true)
expect(articleEditorBody(wrapper)).toBe(
paragraph('Revised opening paragraph.'),
)
expect(wrapper.findAll('button').some((button) => button.text() === 'Retry')).toBe(true)
expect(articleEditorBody(wrapper)).toBe(paragraph('Revised opening paragraph.'))
})
})
@@ -406,9 +360,7 @@ describe('Retry resends the same body and can recover', () => {
await saveButton(wrapper).trigger('click')
await flushPromises()
const retryButton = wrapper
.findAll('button')
.find((button) => button.text() === 'Retry')
const retryButton = wrapper.findAll('button').find((button) => button.text() === 'Retry')
await retryButton?.trigger('click')
await flushPromises()
@@ -444,14 +396,8 @@ describe('Near the limit — the counter warns at 9000 characters without blocki
await typeBody(wrapper, paragraphOfLength(9240))
expect(wrapper.text()).toContain('9 240 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(true)
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
})
})
@@ -466,26 +412,16 @@ describe('Too long — over 10000 characters disables Save and Publish until sho
expect(wrapper.text()).toContain(
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
)
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
const publishButton = wrapper
.findAll('button')
.find(
(button) =>
button.text() === 'Publish' ||
button.text() === 'Save and publish',
)
expect((publishButton?.element as HTMLButtonElement).disabled).toBe(
true,
)
.find((button) => button.text() === 'Publish' || button.text() === 'Save and publish')
expect((publishButton?.element as HTMLButtonElement).disabled).toBe(true)
await typeBody(wrapper, paragraphOfLength(9000))
expect(wrapper.text()).not.toContain('characters over')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
})
})
@@ -501,12 +437,10 @@ describe('Preview shows the rendered title and body without the editing surface'
await flushPromises()
expect(wrapper.findComponent(ArticleEditor).exists()).toBe(false)
expect(
wrapper.find('[data-testid="article-preview"]').text(),
).toContain('How we price a bathroom renovation')
expect(
wrapper.find('[data-testid="article-preview"]').html(),
).toContain('Some body')
expect(wrapper.find('[data-testid="article-preview"]').text()).toContain(
'How we price a bathroom renovation',
)
expect(wrapper.find('[data-testid="article-preview"]').html()).toContain('Some body')
await previewSwitch.trigger('click')
await flushPromises()
@@ -523,24 +457,18 @@ describe('Offline blocks Save and Publish, and recovers when the connection retu
})
await typeBody(wrapper, paragraphOfLength(4320))
const onLineSpy = vi
.spyOn(navigator, 'onLine', 'get')
.mockReturnValue(false)
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
expect(wrapper.text()).toContain('Waiting for connection')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
onLineSpy.mockReturnValue(true)
window.dispatchEvent(new Event('online'))
await flushPromises()
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
expect(wrapper.text()).toContain('Unsaved changes')
onLineSpy.mockRestore()
})
@@ -553,9 +481,7 @@ describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds',
})
await typeBody(wrapper, paragraphOfLength(4320))
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
expect(articlesLink).toBeTruthy()
const navigation = router.push('/article')
@@ -584,9 +510,7 @@ describe('Leaving with unsaved changes, "Keep editing" stays on the page', () =>
})
await typeBody(wrapper, paragraphOfLength(4320))
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
expect(articlesLink).toBeTruthy()
const navigation = router.push('/article')
@@ -627,9 +551,7 @@ describe('Leaving a clean article never prompts', () => {
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
await articlesLink?.trigger('click')
await flushPromises()
@@ -644,18 +566,14 @@ describe('Session expired mid-save keeps the draft and restores it after logging
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () => mockResponse(401),
})
const body = paragraph(
'Paragraph added just before the session expired.',
)
const body = paragraph('Paragraph added just before the session expired.')
await typeBody(wrapper, body)
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBe(body)
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query.redirect).toBe(
'/article/12/edit',
)
expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit')
expect(router.currentRoute.value.query.reason).toBe('expired')
const session = useSessionStore()
@@ -685,6 +603,108 @@ describe('Session expired mid-save keeps the draft and restores it after logging
})
})
function articleWithImageFixture(overrides: Partial<Article> = {}) {
return articleFixture({
body: '<p>Body</p><img src="/static/uploadedImages/before.png" alt="" data-image-id="10">',
...overrides,
})
}
async function requestRemoveImage(wrapper: Awaited<ReturnType<typeof mount>>, imageId = 10) {
await wrapper.findComponent(ArticleEditor).vm.$emit('request-remove-image', imageId)
await flushPromises()
}
describe('Requesting removal of an inline image opens the confirm dialog', () => {
it('opens ConfirmDialog with the exact copy', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.title).toBe('Remove this image?')
expect(props.description).toBe(
"This image will be removed from the article. This can't be undone.",
)
expect(props.confirmLabel).toBe('Remove image')
expect(props.cancelLabel).toBe('Cancel')
expect(props.pendingLabel).toBe('Removing…')
expect(props.tone).toBe('danger')
})
})
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 () => {
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((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
expect(toastAdd).toHaveBeenCalledWith({
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.',
})
})
})
describe('An unknown or deleted article id replaces the editor with an error state', () => {
it('shows the not-found error state and navigates to /article', async () => {
const wrapper = await mountEditor(