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

+25 -3
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/core'
import { ArticleIndent } from '@/lib/articleIndent'
import { ArticleImage, findArticleImagePos } from '@/lib/articleImage'
const props = withDefaults(
defineProps<{
@@ -18,10 +19,16 @@ const props = withDefaults(
const emit = defineEmits<{
'update:modelValue': [value: string]
'request-image': []
'request-remove-image': [imageId: number]
}>()
const editorInstance = ref<Editor | null>(null)
const extensions = [ArticleIndent]
const extensions = [
ArticleIndent,
ArticleImage.configure({
onRequestRemove: (imageId: number) => emit('request-remove-image', imageId),
}),
]
function captureEditor(editor: Editor) {
editorInstance.value = editor
@@ -198,8 +205,22 @@ defineExpose({
focus() {
editorInstance.value?.commands.focus()
},
insertImage(src: string, alt: string) {
editorInstance.value?.commands.setImage({ src, alt })
insertImage(src: string, alt: string, imageId: number) {
editorInstance.value?.commands.setArticleImage({ src, alt, imageId })
},
removeImage(imageId: number) {
const editor = editorInstance.value
if (!editor) {
return
}
const pos = findArticleImagePos(editor, imageId)
if (pos === null) {
return
}
editor
.chain()
.deleteRange({ from: pos, to: pos + 1 })
.run()
},
})
</script>
@@ -209,6 +230,7 @@ defineExpose({
v-slot="{ editor }"
:model-value="props.modelValue"
:extensions="extensions"
:image="false"
content-type="html"
:editable="!props.disabled"
:ui="{ base: 'article-content' }"
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
import { articleImageId } from '@/lib/articleImage'
const props = defineProps(nodeViewProps)
const imageId = computed(() => articleImageId(props.node))
const showDeleteButton = computed(() => props.selected && imageId.value !== null)
interface ImageNodeAttrs {
src?: string
alt?: string
title?: string
width?: number | string
height?: number | string
}
const imageAttrs = computed(() => {
const { src, alt, title, width, height } = props.node.attrs as ImageNodeAttrs
return { src, alt, title, width, height, 'data-image-id': props.node.attrs['data-image-id'] }
})
function onDeleteClick() {
if (imageId.value === null) {
return
}
props.extension.options.onRequestRemove(imageId.value)
}
</script>
<template>
<NodeViewWrapper
as="div"
class="relative block w-fit"
:class="{ 'outline-2 outline-primary rounded-md': selected }"
data-testid="article-editor-image"
@dragstart.prevent
>
<img v-bind="imageAttrs" draggable="false" class="block max-w-full" />
<UButton
v-if="showDeleteButton"
data-testid="remove-image-button"
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
class="absolute top-1 right-1 bg-default"
aria-label="Remove image"
@mousedown.prevent
@click="onDeleteClick"
/>
</NodeViewWrapper>
</template>
+2 -2
View File
@@ -41,7 +41,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
uploaded: [location: string]
uploaded: [location: string, imageId: number]
close: []
}>()
@@ -167,7 +167,7 @@ async function runUpload(file: File) {
const outcome = await start(props.articleId, file)
if (outcome.kind === 'success') {
succeeded.value = true
emit('uploaded', outcome.location)
emit('uploaded', outcome.location, outcome.id)
closeTimer = setTimeout(() => {
open.value = false
}, 600)
@@ -0,0 +1,108 @@
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 RootComponent from '@/App.vue'
import ArticleEditor from '@/components/ArticleEditor.vue'
vi.mock('@nuxt/ui/composables', () => ({
useOverlay: () => ({ create: () => ({ open: () => Promise.resolve(false) }) }),
useToast: () => ({ add: vi.fn() }),
}))
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountArticleEditor(body = '<p>Hello</p>') {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = vi.fn(() =>
Promise.resolve(
mockResponse(200, {
id: 12,
title: 'T',
body,
images: [],
is_online: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}),
),
) 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
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
await router.push('/login')
})
describe('insertImage stamps the inserted image with its numeric id', () => {
it('renders data-image-id on the img and emits the updated body', async () => {
const wrapper = await mountArticleEditor()
const editor = wrapper.findComponent(ArticleEditor)
;(
editor.vm as unknown as { insertImage: (src: string, alt: string, id: number) => void }
).insertImage('/static/uploadedImages/photo.png', 'alt text', 10)
await flushPromises()
expect(editor.props('modelValue')).toContain(
'<img src="/static/uploadedImages/photo.png" alt="alt text" data-image-id="10">',
)
})
})
describe('removeImage removes only the node whose data-image-id matches', () => {
it('deletes the matching image node and leaves the rest of the content', async () => {
const wrapper = await mountArticleEditor()
const editor = wrapper.findComponent(ArticleEditor)
const vm = editor.vm as unknown as {
insertImage: (src: string, alt: string, id: number) => void
removeImage: (id: number) => void
}
vm.insertImage('/static/uploadedImages/a.png', '', 10)
await flushPromises()
vm.insertImage('/static/uploadedImages/b.png', '', 11)
await flushPromises()
vm.removeImage(10)
await flushPromises()
const body = editor.props('modelValue') as string
expect(body).not.toContain('data-image-id="10"')
expect(body).toContain('data-image-id="11"')
})
it('does nothing when no image carries the given id', async () => {
const wrapper = await mountArticleEditor('<p>Hello</p>')
const editor = wrapper.findComponent(ArticleEditor)
const vm = editor.vm as unknown as { removeImage: (id: number) => void }
vm.removeImage(999)
await flushPromises()
expect(editor.emitted('update:modelValue')).toBeFalsy()
})
})
@@ -112,7 +112,7 @@ const Host = defineComponent({
const open = ref(false)
return {
open,
onUploaded: (location: string) => emit('uploaded', location),
onUploaded: (location: string, imageId: number) => emit('uploaded', location, imageId),
onClose: () => emit('close'),
}
},
@@ -309,10 +309,13 @@ describe('A successful upload emits the location and auto-closes 600 ms later',
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
xhr.resolve(201, JSON.stringify({ location: '/static/uploadedImages/north-wall.jpg' }))
xhr.resolve(
201,
JSON.stringify({ id: 7, location: '/static/uploadedImages/north-wall.jpg' }),
)
await vi.advanceTimersByTimeAsync(0)
expect(wrapper.emitted('uploaded')).toEqual([['/static/uploadedImages/north-wall.jpg']])
expect(wrapper.emitted('uploaded')).toEqual([['/static/uploadedImages/north-wall.jpg', 7]])
expect(document.body.textContent).toContain('Image added')
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
+6 -3
View File
@@ -6,7 +6,7 @@ export interface UploadProgress {
}
export type UploadOutcome =
| { kind: 'success'; location: string }
| { kind: 'success'; location: string; id: number }
| { kind: 'rejected'; body: string }
| { kind: 'failed' }
@@ -32,8 +32,11 @@ export function useImageUpload() {
request.addEventListener('load', () => {
activeRequest = null
if (request.status === 201) {
const body = JSON.parse(request.responseText) as { location: string }
resolve({ kind: 'success', location: body.location })
const body = JSON.parse(request.responseText) as {
id: number
location: string
}
resolve({ kind: 'success', location: body.location, id: body.id })
return
}
if (request.status === 400) {
+248
View File
@@ -0,0 +1,248 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { ArticleImage, findArticleImagePos } from '../articleImage'
function mountHarness(onRequestRemove = vi.fn()) {
let editor: Editor
const Harness = defineComponent({
components: { EditorContent },
setup() {
editor = new Editor({
extensions: [StarterKit, ArticleImage.configure({ onRequestRemove })],
content: '<p>Hello</p>',
})
return { editor }
},
template: '<EditorContent :editor="editor" />',
})
const wrapper = mount(Harness, { attachTo: document.body })
return { wrapper, onRequestRemove, getEditor: () => editor! }
}
function deleteButton() {
return document.body.querySelector<HTMLButtonElement>('[data-testid="remove-image-button"]')
}
function flush() {
return new Promise((resolve) => setTimeout(resolve, 20))
}
function dispatchKeydown(editor: Editor, key: string) {
editor.view.dom.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
)
}
let activeWrapper: ReturnType<typeof mountHarness>['wrapper'] | null = null
afterEach(() => {
activeWrapper?.unmount()
activeWrapper = null
})
describe('Selecting an inserted image reveals its delete button', () => {
it('shows the delete button when selected and hides it once deselected', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
await flush()
expect(deleteButton()).toBeTruthy()
editor.commands.setTextSelection(0)
await flush()
expect(deleteButton()).toBeFalsy()
})
})
describe('An image with no data-image-id gets no delete affordance', () => {
it('renders no delete button even when the node is selected', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setImage({ src: 'legacy.png', alt: '' })
let pos: number | null = null
editor.state.doc.descendants((node, nodePos) => {
if (node.type.name === 'image') {
pos = nodePos
}
})
editor.commands.setNodeSelection(pos as number)
await flush()
expect(deleteButton()).toBeFalsy()
})
})
describe('Activating the delete button triggers removal without deleting the node', () => {
it('calls onRequestRemove with the image id and leaves the node in place', async () => {
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
await flush()
deleteButton()?.click()
expect(onRequestRemove).toHaveBeenCalledWith(10)
expect(findArticleImagePos(editor, 10)).not.toBeNull()
})
})
describe('Pressing Backspace on a selected image triggers removal without deleting the node', () => {
it('calls onRequestRemove and leaves the image node in the document', () => {
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
dispatchKeydown(editor, 'Backspace')
expect(onRequestRemove).toHaveBeenCalledWith(10)
expect(findArticleImagePos(editor, 10)).not.toBeNull()
})
})
describe('Pressing Delete on a selected image triggers removal without deleting the node', () => {
it('calls onRequestRemove and leaves the image node in the document', () => {
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
dispatchKeydown(editor, 'Delete')
expect(onRequestRemove).toHaveBeenCalledWith(10)
expect(findArticleImagePos(editor, 10)).not.toBeNull()
})
})
describe('Backspace on a pre-existing image with no data-image-id is not intercepted', () => {
it('does not call onRequestRemove, letting native deletion proceed', () => {
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setImage({ src: 'legacy.png', alt: '' })
let pos: number | null = null
editor.state.doc.descendants((node, nodePos) => {
if (node.type.name === 'image') {
pos = nodePos
}
})
editor.commands.setNodeSelection(pos as number)
dispatchKeydown(editor, 'Backspace')
expect(onRequestRemove).not.toHaveBeenCalled()
})
})
describe('The custom image node view renders as a block-level element', () => {
it('uses a div wrapper so consecutive images stack vertically', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
await flush()
const node = document.body.querySelector<HTMLElement>(
'[data-testid="article-editor-image"]',
)
expect(node?.tagName).toBe('DIV')
expect(node ? getComputedStyle(node).display : null).toBe('block')
})
})
describe('Selecting an image shows a visible selection outline', () => {
it('applies the selected outline classes to the wrapper when the node is selected', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
await flush()
const node = document.body.querySelector<HTMLElement>(
'[data-testid="article-editor-image"]',
)
expect(node?.classList.contains('outline-primary')).toBe(true)
})
})
describe('The custom image node view preserves width, height and title attributes', () => {
it('renders width, height and title on the img element', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.insertContent({
type: 'image',
attrs: {
src: 'photo.png',
alt: 'alt text',
title: 'a title',
width: 320,
height: 240,
'data-image-id': 10,
},
})
await flush()
const img = document.body.querySelector<HTMLImageElement>(
'[data-testid="article-editor-image"] img',
)
expect(img?.getAttribute('width')).toBe('320')
expect(img?.getAttribute('height')).toBe('240')
expect(img?.getAttribute('title')).toBe('a title')
})
})
describe('The image node is not natively draggable', () => {
it('disables native dragging on the img element', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
await flush()
const node = document.body.querySelector<HTMLElement>(
'[data-testid="article-editor-image"]',
)
const img = node?.querySelector<HTMLImageElement>('img')
expect(img?.getAttribute('draggable')).toBe('false')
})
})
describe('removeImage removes the matching image node from the document', () => {
it('deletes only the node whose data-image-id matches', () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'a.png', alt: '', imageId: 10 })
editor.commands.setArticleImage({ src: 'b.png', alt: '', imageId: 11 })
const pos = findArticleImagePos(editor, 10) as number
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + 1 })
.run()
expect(findArticleImagePos(editor, 10)).toBeNull()
expect(findArticleImagePos(editor, 11)).not.toBeNull()
})
})
+120
View File
@@ -0,0 +1,120 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import Image from '@tiptap/extension-image'
import { NodeSelection } from '@tiptap/pm/state'
import { VueNodeViewRenderer } from '@tiptap/vue-3'
import ArticleEditorImageNode from '@/components/ArticleEditorImageNode.vue'
export interface ArticleImageOptions {
onRequestRemove: (imageId: number) => void
}
export interface ArticleImageEditorLike {
state: {
selection: unknown
doc: ProseMirrorNode
}
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
articleImage: {
setArticleImage: (options: { src: string; alt?: string; imageId: number }) => ReturnType
}
}
}
export function articleImageId(node: { attrs: Record<string, unknown> }): number | null {
const raw = node.attrs['data-image-id']
if (raw === null || raw === undefined || raw === '') {
return null
}
const id = Number(raw)
return Number.isNaN(id) ? null : id
}
export function selectedArticleImageId(editor: ArticleImageEditorLike): number | null {
const selection = editor.state.selection
if (!(selection instanceof NodeSelection)) {
return null
}
if (selection.node.type.name !== 'image') {
return null
}
return articleImageId(selection.node)
}
export function findArticleImagePos(
editor: ArticleImageEditorLike,
imageId: number,
): number | null {
let targetPos: number | null = null
editor.state.doc.descendants((node, pos) => {
if (targetPos !== null) {
return false
}
if (node.type.name === 'image' && articleImageId(node) === imageId) {
targetPos = pos
return false
}
return true
})
return targetPos
}
export const ArticleImage = Image.extend<ArticleImageOptions>({
addOptions() {
return {
...this.parent?.(),
onRequestRemove: () => {},
}
},
addAttributes() {
return {
...this.parent?.(),
'data-image-id': {
default: null,
parseHTML: (element) => element.getAttribute('data-image-id'),
renderHTML: (attributes) => {
if (!attributes['data-image-id']) {
return {}
}
return { 'data-image-id': attributes['data-image-id'] }
},
},
}
},
addNodeView() {
return VueNodeViewRenderer(ArticleEditorImageNode)
},
addCommands() {
return {
...this.parent?.(),
setArticleImage:
(options: { src: string; alt?: string; imageId: number }) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: {
src: options.src,
alt: options.alt,
'data-image-id': options.imageId,
},
})
},
}
},
addKeyboardShortcuts() {
const triggerRemoval = () => {
const imageId = selectedArticleImageId(this.editor)
if (imageId === null) {
return false
}
this.options.onRequestRemove(imageId)
return true
}
return {
Backspace: triggerRemoval,
Delete: triggerRemoval,
}
},
})
+34 -2
View File
@@ -221,8 +221,8 @@ function onEditorDrop(event: DragEvent) {
openImageUploader(file)
}
function onImageUploaded(location: string) {
editorRef.value?.insertImage(location, '')
function onImageUploaded(location: string, imageId: number) {
editorRef.value?.insertImage(location, '', imageId)
toast.add({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -231,6 +231,37 @@ function onImageUploaded(location: string) {
})
}
async function performRemoveImage(imageId: number) {
try {
await request(`/v1/article/${articleId}/image/${imageId}`, { method: 'DELETE' })
} catch {
throw new Error("Couldn't remove the image, so it's still there. Try again in a moment.")
}
editorRef.value?.removeImage(imageId)
toast.add({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image removed',
description: 'Save to keep this change.',
})
}
async function onRequestRemoveImage(imageId: number) {
await overlay
.create(ConfirmDialog, {
props: {
title: 'Remove this image?',
description: "This image will be removed from the article. This can't be undone.",
confirmLabel: 'Remove image',
cancelLabel: 'Cancel',
pendingLabel: 'Removing…',
tone: 'danger',
action: () => performRemoveImage(imageId),
},
})
.open()
}
function onImageUploaderClosed() {
imageUploaderFile.value = null
}
@@ -426,6 +457,7 @@ onBeforeRouteLeave(async () => {
:article-id="articleId"
@update:model-value="draft.edit"
@request-image="onRequestImage"
@request-remove-image="onRequestRemoveImage"
>
<template #footer>
<CharacterCounter
+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(