feat: editor image removal QOLs
This commit is contained in:
1 parent
0fc00ceeff
commit
b29d0ed045
23 files changed
+1398
-151
No files matched your search
@@ -9,6 +9,9 @@
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
|
||||
"@tiptap/extension-image": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3",
|
||||
"@tiptap/vue-3": "3.31.3",
|
||||
"@vueuse/core": "14.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"shiki": "^4.4.3",
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
|
||||
"@tiptap/extension-image": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3",
|
||||
"@tiptap/vue-3": "3.31.3",
|
||||
"@vueuse/core": "14.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"shiki": "^4.4.3",
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user