feat: image upload on articles

This commit is contained in:
fsylvain committed 2026-09-28 02:28:20 +02:00
1 parent d4fcfb2bba
commit 938263cd6a
12 files changed
+1436 -65

No files matched your search

+74 -56
View File
@@ -11,6 +11,7 @@ import SaveStatus from '@/components/SaveStatus.vue'
import PublishControl from '@/components/PublishControl.vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ImageUploader from '@/components/ImageUploader.vue'
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import { useArticleDraft } from '@/composables/useArticleDraft'
@@ -35,6 +36,9 @@ const article = ref<Article | null>(null)
const previewOn = ref(false)
const detailsOpen = ref(false)
const publishPending = ref<string | undefined>(undefined)
const editorRef = ref<InstanceType<typeof ArticleEditor> | null>(null)
const imageUploaderOpen = ref(false)
const imageUploaderFile = ref<File | null>(null)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
function clearSkeletonTimer() {
@@ -73,15 +77,10 @@ const bodyLength = computed(() => draft.body.value.length)
const overLimit = computed(() => bodyLength.value > BODY_MAX_LENGTH)
const saveEnabled = computed(
() =>
draft.state.value === 'dirty' &&
!overLimit.value &&
loadState.value === 'loaded',
() => draft.state.value === 'dirty' && !overLimit.value && loadState.value === 'loaded',
)
const publishDisabled = computed(
() => overLimit.value || draft.state.value === 'offline',
)
const publishDisabled = computed(() => overLimit.value || draft.state.value === 'offline')
async function runSave() {
const outcome = await draft.save()
@@ -205,6 +204,37 @@ async function handleDelete() {
.open()
}
function openImageUploader(file: File | null) {
imageUploaderFile.value = file
imageUploaderOpen.value = true
}
function onRequestImage() {
openImageUploader(null)
}
function onEditorDrop(event: DragEvent) {
const file = event.dataTransfer?.files?.[0]
if (!file) {
return
}
openImageUploader(file)
}
function onImageUploaded(location: string) {
editorRef.value?.insertImage(location, '')
toast.add({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image added',
description: 'Save to keep it in the article.',
})
}
function onImageUploaderClosed() {
imageUploaderFile.value = null
}
function onKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
@@ -248,21 +278,14 @@ onBeforeRouteLeave(async () => {
<DashboardShell mobile-title="Edit article" full-bleed>
<template v-if="loadState === 'loaded'" #breadcrumb>
<UBreadcrumb
:items="[
{ label: 'Articles', to: '/article' },
{ label: article?.title ?? '' },
]"
:items="[{ label: 'Articles', to: '/article' }, { label: article?.title ?? '' }]"
color="neutral"
class="hidden lg:block"
/>
</template>
<ErrorView v-if="loadState === 'notFound'" kind="article-not-found" />
<ErrorView
v-else-if="loadState === 'error'"
kind="error"
@retry="loadArticle"
/>
<ErrorView v-else-if="loadState === 'error'" kind="error" @retry="loadArticle" />
<div
v-else-if="loadState === 'loading' && showSkeleton"
@@ -281,10 +304,7 @@ onBeforeRouteLeave(async () => {
aria-label="Back to Articles"
@click="goBack"
/>
<USkeleton
class="h-5 w-80 rounded-none"
data-testid="skeleton-title"
/>
<USkeleton class="h-5 w-80 rounded-none" data-testid="skeleton-title" />
<USkeleton class="h-5.5 w-15 rounded-none" />
</div>
<div
@@ -309,10 +329,7 @@ onBeforeRouteLeave(async () => {
</div>
</div>
<div
v-else-if="loadState === 'loaded' && article"
class="flex min-h-0 flex-1 flex-col"
>
<div v-else-if="loadState === 'loaded' && article" class="flex min-h-0 flex-1 flex-col">
<div
class="flex flex-shrink-0 flex-wrap items-center gap-3 border-b border-default bg-default px-6 py-3.5"
>
@@ -324,9 +341,7 @@ onBeforeRouteLeave(async () => {
aria-label="Back to Articles"
@click="goBack"
/>
<div
class="flex min-w-50 flex-1 flex-wrap items-center gap-2.5"
>
<div class="flex min-w-50 flex-1 flex-wrap items-center gap-2.5">
<h1
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
title="The title can't be changed after creation."
@@ -400,39 +415,42 @@ onBeforeRouteLeave(async () => {
<div
class="hidden w-70 flex-shrink-0 border-l border-default bg-default p-5 lg:block"
>
<ArticleDetails
:article="article"
v-model:open="detailsOpen"
/>
<ArticleDetails :article="article" v-model:open="detailsOpen" />
</div>
</div>
<ArticleEditor
v-else
class="min-h-0 flex-1"
:model-value="draft.body.value"
:article-id="articleId"
@update:model-value="draft.edit"
>
<template #footer>
<CharacterCounter
variant="footer"
:value="bodyLength"
:max="BODY_MAX_LENGTH"
/>
</template>
<template #aside>
<div class="h-full border-l border-default bg-default p-5">
<ArticleDetails
:article="article"
v-model:open="detailsOpen"
<div v-else class="min-h-0 flex-1" @dragover.prevent @drop.prevent="onEditorDrop">
<ArticleEditor
ref="editorRef"
class="h-full"
:model-value="draft.body.value"
:article-id="articleId"
@update:model-value="draft.edit"
@request-image="onRequestImage"
>
<template #footer>
<CharacterCounter
variant="footer"
:value="bodyLength"
:max="BODY_MAX_LENGTH"
/>
</div>
</template>
</ArticleEditor>
</template>
<template #aside>
<div class="h-full border-l border-default bg-default p-5">
<ArticleDetails :article="article" v-model:open="detailsOpen" />
</div>
</template>
</ArticleEditor>
</div>
<div
class="flex flex-shrink-0 gap-2 border-t border-default bg-default p-3 lg:hidden"
>
<ImageUploader
v-model:open="imageUploaderOpen"
:article-id="articleId"
:initial-file="imageUploaderFile"
@uploaded="onImageUploaded"
@close="onImageUploaderClosed"
/>
<div class="flex flex-shrink-0 gap-2 border-t border-default bg-default p-3 lg:hidden">
<UButton
icon="i-lucide-panel-right"
color="neutral"
@@ -0,0 +1,257 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
const FIXED_NOW = '2026-09-26T11:02:00Z'
class FakeXMLHttpRequest {
static instances: FakeXMLHttpRequest[] = []
method = ''
url = ''
status = 0
responseText = ''
withCredentials = false
sentBody: FormData | null = null
private listeners: Record<string, Array<(event?: unknown) => void>> = {}
private uploadListeners: Record<string, (event: { loaded: number; total: number }) => void> = {}
upload = {
addEventListener: (
type: string,
cb: (event: { loaded: number; total: number }) => void,
) => {
this.uploadListeners[type] = cb
},
}
constructor() {
FakeXMLHttpRequest.instances.push(this)
}
open(method: string, url: string) {
this.method = method
this.url = url
}
send(body: FormData) {
this.sentBody = body
}
abort() {
this.dispatch('abort')
}
addEventListener(type: string, cb: (event?: unknown) => void) {
this.listeners[type] = this.listeners[type] || []
this.listeners[type].push(cb)
}
dispatch(type: string, event?: unknown) {
;(this.listeners[type] || []).forEach((cb) => cb(event))
}
resolve(status: number, body: string) {
this.status = status
this.responseText = body
this.dispatch('load')
}
}
function latestXHR() {
return FakeXMLHttpRequest.instances[FakeXMLHttpRequest.instances.length - 1]
}
function mib(value: number) {
return Math.round(value * 1024 * 1024)
}
function fileFixture(name: string, type: string, sizeMB: number) {
const file = new File(['x'], name, { type })
Object.defineProperty(file, 'size', { value: mib(sizeMB), configurable: true })
return file
}
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name] ?? null },
json: async () => body,
} as unknown as Response
}
function routeFor(url: string, init?: RequestInit) {
const method = init?.method ?? 'GET'
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
return handler()
})
}
function articleFixture(overrides: Partial<Article> = {}): Article {
return {
id: 12,
title: 'How we price a bathroom renovation',
body: '<p>a</p>',
images: [],
is_online: false,
created_at: '2026-03-12T09:14:00Z',
updated_at: FIXED_NOW,
...overrides,
}
}
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', () => ({
useOverlay: () => ({ create: vi.fn() }),
useToast: () => ({ add: toastAdd }),
}))
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountEditor(
handlers: Record<string, () => Response | Promise<Response>>,
path = '/article/12/edit',
) {
setActivePinia(createPinia())
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
await router.push(path)
const wrapper = mount(RootComponent, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
function saveButton(wrapper: Awaited<ReturnType<typeof mount>>) {
return wrapper.find('[data-testid="save-button"]')
}
function findButton(text: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
function toolbarImageButton(wrapper: Awaited<ReturnType<typeof mount>>) {
return wrapper.findAll('button').find((button) => button.text() === 'Image')
}
const RealXMLHttpRequest = global.XMLHttpRequest
beforeEach(() => {
FakeXMLHttpRequest.instances = []
global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest
})
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
sessionStorage.clear()
global.XMLHttpRequest = RealXMLHttpRequest
await router.push('/login')
})
describe("Opening the uploader from the toolbar's image button shows the idle state", () => {
it('opens ImageUploader idle and sends no request', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await toolbarImageButton(wrapper)?.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('Add an image')
expect(document.body.textContent).toContain('Drop an image here')
expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
expect(findButton('Choose file')).toBeTruthy()
expect(findButton('Cancel')).toBeTruthy()
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
})
})
describe('Dropping a valid file onto the editor opens the uploader already uploading it', () => {
it('opens uploading, not idle, and sends the file', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
const file = fileFixture('before.png', 'image/png', 3.9)
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: { 0: file, length: 1 } },
})
wrapper.findComponent(ArticleEditor).element.dispatchEvent(dropEvent)
await flushPromises()
expect(document.body.textContent).toContain('Add an image')
expect(document.body.textContent).not.toContain('Drop an image here')
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
expect(latestXHR().url).toBe('/v1/article/12/image')
expect((latestXHR().sentBody?.get('file') as File).name).toBe('before.png')
})
})
describe('A successful upload inserts the image at the cursor and marks the draft dirty', () => {
it('inserts the image, marks the draft dirty and toasts', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await toolbarImageButton(wrapper)?.trigger('click')
await flushPromises()
const fileInput = document.body.querySelector<HTMLInputElement>('input[type="file"]')!
const file = fileFixture('north-wall.jpg', 'image/jpeg', 3.9)
Object.defineProperty(fileInput, 'files', {
value: { 0: file, length: 1 },
configurable: true,
})
fileInput.dispatchEvent(new Event('change'))
await flushPromises()
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
latestXHR().resolve(
201,
JSON.stringify({ location: '/static/uploadedImages/north-wall.jpg' }),
)
await flushPromises()
const insertedBody = wrapper.findComponent(ArticleEditor).props('modelValue')
expect(insertedBody).toContain('/static/uploadedImages/north-wall.jpg')
expect(insertedBody).toContain('<img src="/static/uploadedImages/north-wall.jpg" alt="">')
expect(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 added',
description: 'Save to keep it in the article.',
})
})
})