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

+136
View File
@@ -0,0 +1,136 @@
Feature: Wiring the editor's image upload flow
As a verified RenewCMS owner writing an article
I want the editor's image button (and dropping a file onto the editor) to open an upload
modal that validates the file, shows progress, and inserts the result at my cursor
So that I can add images to an article's body without leaving the editor, with clear
feedback when a file is rejected or the upload fails
Background:
Given EditorView.vue listens for ArticleEditor's "request-image" event and, on it, opens
ImageUploader (a UModal titled "Add an image") with the current article's id
And dropping a file directly onto the editor surface also opens ImageUploader, with that
file already provided to it instead of an empty idle state
And ImageUploader validates a chosen or dropped file in the browser, checking both its MIME
type and its file extension against png/jpeg/webp/svg, and that its size is at most
10 × 1024 × 1024 bytes, before sending anything
And a file that passes validation is uploaded via a new useImageUpload composable, using
XMLHttpRequest (not useApi) so upload progress is observable, as multipart/form-data with
the file under the field name "file", to POST /v1/article/{articleId}/image, per
features/upload_article_image.feature's already-implemented and already-mounted contract
And a 201 response's {"location": "..."} is inserted into ArticleEditor's content at the
current cursor position via an exposed insertImage(src, alt) method, with alt ""; this
moves useArticleDraft's state to "dirty" the same way any other body edit does
And closing the modal while an upload is in flight aborts the XMLHttpRequest
And a 401 response is handled entirely by useApi's global handling
(features/api_client_global_error_handling.feature) and is out of scope for new scenarios
here; the same is true of a 404 (the article was deleted elsewhere), which the editor's
existing not-found handling already covers (features/editor_core.feature)
Scenario: Opening the uploader from the toolbar's image button shows the idle state
Given the user is on /article/12/edit with the editor focused
When the user activates the toolbar's "Image" button
Then ImageUploader opens titled "Add an image"
And it shows "Drop an image here", a "Choose file" action, and the help text
"PNG, JPEG, WEBP, SVG · max 10 MB"
And its cancel action reads "Cancel"
And no request has been sent
Scenario: Dragging a file over the editor surface shows the dragging-over state
Given ImageUploader is open in the idle state
When the user drags a file over the drop zone
Then the drop zone reads "Drop to upload"
Scenario: Dropping a valid file onto the editor opens the uploader already uploading it
Given the user is on /article/12/edit with the editor focused and ImageUploader is not open
When the user drops a file named "before.png", content type "image/png", size 3.9 MB, onto
the editor surface
Then ImageUploader opens titled "Add an image"
And POST /v1/article/12/image is sent with that file under the field name "file"
And the uploading state is shown, not the idle state
Scenario Outline: Choosing a file the browser can reject never sends a request
Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "<filename>", content type "<content_type>", size <size>
Then no request is sent to POST /v1/article/12/image
And the rejected state shows the title "\"<filename>\" can't be used"
And its text reads "<description>"
And its confirm action reads "Choose another file" and its cancel action reads "Cancel"
Examples:
| filename | content_type | size | description |
| plan.pdf | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. |
| disguised.png | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. |
| north-wall.tif | image/png | 14.2 MB | north-wall.tif is 14.2 MB. Images can be up to 10 MB. |
Scenario: "Choose another file" from the rejected state returns to idle
Given ImageUploader is in the rejected state for a file named "plan.pdf"
When the user activates "Choose another file"
Then the idle state is shown again, with the file picker available
Scenario: A valid upload shows progress and its cancel action reads "Cancel upload"
Given ImageUploader is open in the idle state for article 12
When the user chooses a file named "north-wall.jpg", content type "image/jpeg", size 3.9 MB
Then POST /v1/article/12/image is sent with that file under the field name "file"
And the uploading state shows "north-wall.jpg 0%" and "Uploading 0.0 of 3.9 MB"
And its cancel action reads "Cancel upload"
When the upload reports 2.4 MB sent of 3.9 MB
Then the uploading state shows "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB"
Scenario: Closing the dialog during an upload cancels it
Given ImageUploader is uploading a file named "north-wall.jpg" to article 12
When the user activates "Cancel upload" (or otherwise closes the modal)
Then the in-flight XMLHttpRequest is aborted
And the modal closes
And the editor's body is unchanged and remains in its previous dirty/clean state
Scenario: A successful upload inserts the image at the cursor and marks the draft dirty
Given the user is on /article/12/edit in the Saved state and ImageUploader is uploading a
file named "north-wall.jpg" to article 12
When POST /v1/article/12/image resolves 201 with {"location": "/static/uploadedImages/north-wall.jpg"}
Then the uploading state briefly shows "Image added"
And the image at "/static/uploadedImages/north-wall.jpg" is inserted into the editor's
content at the cursor via insertImage
And SaveStatus reads "Unsaved changes" and the Save button becomes enabled
And a toast appears: color success, icon i-lucide-circle-check, title "Image added",
description "Save to keep it in the article."
And the modal closes 600 ms after the response resolves
Scenario Outline: A server-side upload failure keeps the article unchanged and offers a retry
Given ImageUploader is uploading a file named "north-wall.jpg" to article 12
When POST /v1/article/12/image <failure>
Then the failed state shows the title "Upload failed"
And its text reads "The server didn't accept the file. Your article hasn't changed."
And its confirm action reads "Try again"
And the editor's body is unchanged, and no image is inserted
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: "Try again" re-attempts the upload with the same file
Given ImageUploader is in the failed state for a file named "north-wall.jpg", article 12,
after a 500 response
When the user activates "Try again"
Then POST /v1/article/12/image is sent again with that same file under the field name "file"
And the uploading state is shown
Scenario: A server 400 for a type the client should have caught shows the same rejected copy
Given ImageUploader is uploading a file named "notes.pdf" to article 12 (a type that
defensively passed the browser's own check, e.g. a MIME type the browser misreported)
When POST /v1/article/12/image resolves 400
Then the rejected state shows the title "\"notes.pdf\" can't be used"
And its text reads "Use a PNG, JPEG, WEBP or SVG image, up to 10 MB."
And its confirm action reads "Choose another file"
And the editor's body is unchanged, and no image is inserted
Scenario: A server 400 for a size the client should have caught shows the same size-named copy
Given ImageUploader is uploading a file named "north-wall.tif" to article 12 (a file that
defensively passed the browser's own size check somehow, e.g. its reported size changed
between selection and send)
When POST /v1/article/12/image resolves 400
Then the rejected state shows the title "\"north-wall.tif\" can't be used"
And its text reads "north-wall.tif is 14.2 MB. Images can be up to 10 MB."
And its confirm action reads "Choose another file"
And the editor's body is unchanged, and no image is inserted
+90
View File
@@ -0,0 +1,90 @@
# QA: Wiring the editor's image upload flow
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `XMLHttpRequest` (a fake implementing `open`/`setRequestHeader`/`send`/`abort` and firing
`upload.progress`, `load`, `error`) for `POST /v1/article/{id}/image`; useApi/fetch is not
involved in this slice. Mount EditorPage inside DashboardShell at `/article/12/edit` with the
editor_core.qa.md fixture article (id 12, title "How we price a bathroom renovation", is_online
false), GET already resolved 200.
1. **Toolbar button opens the idle state** — click the toolbar's "Image" button; assert
ImageUploader opens titled "Add an image", showing "Drop an image here", "Choose file",
"PNG, JPEG, WEBP, SVG · max 10 MB", cancel "Cancel"; assert no XHR was created.
2. **Dragging over shows "Drop to upload"** — with the uploader open idle, fire a `dragover` on
the drop zone; assert the text changes to "Drop to upload".
3. **Dropping a file on the editor opens the uploader already uploading** — with the uploader
closed, dispatch a `drop` event with a file `before.png` (image/png, 3.9 MB) on the editor
surface; assert ImageUploader opens and an XHR to `/v1/article/12/image` is created with that
file, and the uploading state (not idle) is shown.
4. **Rejected — Examples-driven on type, extension mismatch, and size** — for each of `plan.pdf`
(application/pdf, 1.2 MB), `disguised.png` (application/pdf, 1.2 MB), and `north-wall.tif`
(image/png, 14.2 MB), choose the file via the `UFileUpload`; assert no XHR is created and the
rejected state shows the exact title/text/confirm/cancel copy from the feature's Examples.
5. **"Choose another file" returns to idle** — from the rejected state, click "Choose another
file"; assert the idle state re-renders.
6. **Uploading shows live progress and "Cancel upload"** — choose `north-wall.jpg` (image/jpeg,
3.9 MB); assert the XHR is created with that file under field `file`; assert cancel reads
"Cancel upload"; fire an `upload.progress` event with `loaded: 2.4 * 1024 * 1024, total: 3.9 *
1024 * 1024`; assert the text reads "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB".
7. **Closing during an upload aborts the XHR** — from the uploading state, click "Cancel
upload"; assert the fake XHR's `abort` was called, the modal closes, and the editor's body
and dirty state are unchanged from before the upload started.
8. **A 201 inserts at the cursor and marks the draft dirty** — from the uploading state, resolve
the fake XHR with status 201 and body `{"location": "/static/uploadedImages/north-wall.jpg"}`;
assert `insertImage` (or the editor's exposed method) was called with that location; assert
SaveStatus now reads "Unsaved changes" and Save is enabled; assert a toast appears with color
success, icon `i-lucide-circle-check`, title "Image added", description "Save to keep it in
the article."; advance fake timers 600 ms and assert the modal has closed.
9. **A 500 or network failure shows "Upload failed" and keeps the article unchanged, Examples-
driven** — for (a) the fake XHR firing `load` with status 500 and (b) firing `error`; assert
the failed state shows title "Upload failed", text "The server didn't accept the file. Your
article hasn't changed.", confirm "Try again"; assert no `insertImage` call and the editor's
body/dirty state is unchanged.
10. **"Try again" resends the same file** — from the failed state for `north-wall.jpg`, click
"Try again"; assert a new XHR is created for `/v1/article/12/image` with the same file, and
the uploading state is shown again.
11. **A server 400 for a type the client should have caught shows the rejected copy** — from an
uploading state for `notes.pdf`, resolve the fake XHR with status 400; assert the rejected
state shows title "\"notes.pdf\" can't be used", text "Use a PNG, JPEG, WEBP or SVG image,
up to 10 MB.", confirm "Choose another file"; assert no `insertImage` call.
12. **A server 400 for a size the client should have caught shows the size-named rejected copy**
— from an uploading state for `north-wall.tif`, resolve the fake XHR with status 400; assert
the rejected state shows title "\"north-wall.tif\" can't be used", text "north-wall.tif is
14.2 MB. Images can be up to 10 MB.", confirm "Choose another file"; assert no `insertImage`
call.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/screens/editor.md` states "Upload: choose", "Upload: in progress",
"Upload: rejected", "Upload: inserted", and `docs/design/source/RenewCMS Screens Back-
office.dc.html`. Run `task visual:check -- editor` for each state below; add each state's mock
setup to `web/visual/states.ts` first, reusing editor_core.qa.md's fixture article (id 12, title
"How we price a bathroom renovation", draft, "Today, 11:02", 3 images) plus the sample file data
from the board: filename "north-wall.jpg", size 3.9 MB, progress 62% / 2.4 of 3.9 MB uploaded.
- [ ] Upload: choose — modal "Add an image", idle state, cancel "Cancel".
- [ ] Upload: in progress — modal "Add an image", uploading "north-wall.jpg 62%" / "Uploading
2.4 of 3.9 MB", cancel "Cancel upload".
- [ ] Upload: rejected — modal "Add an image", rejected state for a sample rejected file per the
board's script, cancel "Cancel", confirm "Choose another file".
- [ ] Upload: inserted — toast "Image added" / "Save to keep it in the article.", editor status
"draft", save "dirty".
- [ ] Mobile (all of the above at 390×844): same modal layout constraints as other editor
modals (leading arrow-left button, trailing ellipsis in the header behind it).
- [ ] Keyboard/focus: the drop zone is reachable by Tab and Enter opens the file picker; focus
moves into the modal on open and is trapped there; Esc during "Upload: in progress"
behaves like "Cancel upload" (aborts, closes); the progress bar's `role="progressbar"` /
`aria-valuenow` updates are announced without spamming on every progress tick.
- [ ] Motion: modal open/close follows the standard 240 ms timing; the success state's 600 ms
auto-close is not visually checked as a capture (it's a timing behavior, not a static
state) — cover only via the Vitest fake-timer assertion above.
## Manual verification
- **Real POST round trip against a running server** — Vitest only mocks `XMLHttpRequest`;
confirm a real upload (200/400/401/404/500) against `task dev` with a live SQLite file and the
real `POST /v1/article/{id}/image` handler, since `features/upload_article_image.feature`'s
httptest suite covers the handler in isolation but never through this SPA flow.
- **Real drag-and-drop from the OS file picker** — jsdom's `drop` event is synthetic; confirm a
real OS-level drag of an image file onto the editor in a real browser.
+6 -8
View File
@@ -42,9 +42,7 @@ function currentBlockLabel(editor: Editor) {
}
function isDefaultAlignment(editor: Editor) {
return !['center', 'right', 'justify'].some((align) =>
editor.isActive({ textAlign: align }),
)
return !['center', 'right', 'justify'].some((align) => editor.isActive({ textAlign: align }))
}
function buildItems(editor: Editor): EditorToolbarItem[][] {
@@ -72,8 +70,7 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
{
icon: 'i-lucide-pilcrow',
label: 'Paragraph',
onSelect: () =>
editor.chain().focus().setParagraph().run(),
onSelect: () => editor.chain().focus().setParagraph().run(),
},
{
kind: 'heading',
@@ -201,6 +198,9 @@ defineExpose({
focus() {
editorInstance.value?.commands.focus()
},
insertImage(src: string, alt: string) {
editorInstance.value?.commands.setImage({ src, alt })
},
})
</script>
@@ -226,9 +226,7 @@ defineExpose({
<div class="col-start-1 row-start-3">
<slot name="footer" />
</div>
<div
class="col-start-2 row-span-2 row-start-2 hidden min-h-0 lg:block"
>
<div class="col-start-2 row-span-2 row-start-2 hidden min-h-0 lg:block">
<slot name="aside" />
</div>
</UEditor>
+1 -1
View File
@@ -97,7 +97,7 @@ function actionsFor(article: Article) {
]
}
function onSelect(row: { original: Article }) {
function onSelect(_event: Event, row: { original: Article }) {
router.push(editPath(row.original))
}
</script>
+263
View File
@@ -0,0 +1,263 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
IMAGE_TYPE_REJECTION_TEXT,
formatMegabytes,
isSizeRejectionBody,
rejectionTitle,
sizeRejectionText,
validateImageFile,
} from '@/lib/imageUpload'
import { useImageUpload } from '@/composables/useImageUpload'
const props = defineProps<{
articleId: number
initialFile?: File | null
}>()
const emit = defineEmits<{
uploaded: [location: string]
close: []
}>()
type Phase = 'idle' | 'dragging-over' | 'uploading' | 'rejected' | 'failed'
const open = defineModel<boolean>('open', { default: false })
const phase = ref<Phase>('idle')
const chosenFile = ref<File | null>(null)
const pickedFile = ref<File | null>(null)
const rejectionKind = ref<'type' | 'size' | null>(null)
const succeeded = ref(false)
const { progress, start, abort } = useImageUpload()
let closeTimer: ReturnType<typeof setTimeout> | null = null
const uploadPercent = computed(() => {
if (progress.value.total === 0) {
return 0
}
return Math.round((progress.value.loaded / progress.value.total) * 100)
})
const rejectedTitle = computed(() =>
chosenFile.value ? rejectionTitle(chosenFile.value.name) : '',
)
const rejectedText = computed(() => {
if (!chosenFile.value) {
return ''
}
if (rejectionKind.value === 'size') {
return sizeRejectionText(chosenFile.value.name, chosenFile.value.size)
}
return IMAGE_TYPE_REJECTION_TEXT
})
const cancelLabel = computed(() => (phase.value === 'uploading' ? 'Cancel upload' : 'Cancel'))
const showCancel = computed(
() => phase.value !== 'failed' && !(phase.value === 'uploading' && succeeded.value),
)
function clearCloseTimer() {
if (closeTimer !== null) {
clearTimeout(closeTimer)
closeTimer = null
}
}
async function runUpload(file: File) {
const outcome = await start(props.articleId, file)
if (outcome.kind === 'success') {
succeeded.value = true
emit('uploaded', outcome.location)
closeTimer = setTimeout(() => {
open.value = false
}, 600)
return
}
if (outcome.kind === 'rejected') {
rejectionKind.value = isSizeRejectionBody(outcome.body) ? 'size' : 'type'
phase.value = 'rejected'
return
}
phase.value = 'failed'
}
function handleFile(file: File) {
chosenFile.value = file
const result = validateImageFile(file)
if (!result.ok) {
rejectionKind.value = result.kind
phase.value = 'rejected'
return
}
rejectionKind.value = null
succeeded.value = false
phase.value = 'uploading'
runUpload(file)
}
function onFileChange() {
if (pickedFile.value) {
handleFile(pickedFile.value)
}
}
function onDragOver() {
if (phase.value === 'idle') {
phase.value = 'dragging-over'
}
}
function onDragLeave() {
if (phase.value === 'dragging-over') {
phase.value = 'idle'
}
}
function onDropOnZone() {
if (phase.value === 'dragging-over') {
phase.value = 'idle'
}
}
function chooseAnotherFile() {
phase.value = 'idle'
chosenFile.value = null
pickedFile.value = null
rejectionKind.value = null
}
function tryAgain() {
if (!chosenFile.value) {
return
}
succeeded.value = false
phase.value = 'uploading'
runUpload(chosenFile.value)
}
function cancel() {
open.value = false
}
watch(open, (isOpen) => {
if (isOpen) {
phase.value = 'idle'
chosenFile.value = null
pickedFile.value = null
rejectionKind.value = null
succeeded.value = false
if (props.initialFile) {
handleFile(props.initialFile)
}
return
}
if (phase.value === 'uploading' && !succeeded.value) {
abort()
}
clearCloseTimer()
emit('close')
})
</script>
<template>
<UModal
v-model:open="open"
title="Add an image"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-120 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
title: 'text-lg/6 font-semibold',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}"
>
<template #body>
<div
v-if="phase === 'idle' || phase === 'dragging-over'"
data-testid="image-dropzone"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDropOnZone"
>
<UFileUpload
v-model="pickedFile"
data-testid="image-file-input"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
:multiple="false"
layout="list"
:reset="true"
@change="onFileChange"
>
<template #label>
{{ phase === 'dragging-over' ? 'Drop to upload' : 'Drop an image here' }}
</template>
<template #description>PNG, JPEG, WEBP, SVG · max 10 MB</template>
<template #actions="{ open: openDialog }">
<UButton color="neutral" variant="outline" @click="openDialog()">
Choose file
</UButton>
</template>
</UFileUpload>
</div>
<div v-else-if="phase === 'uploading'" class="flex flex-col gap-3">
<div
class="flex flex-col items-center gap-3 rounded-md border border-default px-6 py-8 text-center"
>
<UIcon
:name="succeeded ? 'i-lucide-circle-check' : 'i-lucide-image-plus'"
:class="succeeded ? 'size-6 text-success' : 'size-6 text-dimmed'"
/>
<p class="text-sm text-highlighted">
<template v-if="succeeded">Image added</template>
<template v-else>{{ chosenFile?.name }} {{ uploadPercent }}%</template>
</p>
<UProgress
v-if="!succeeded"
:model-value="uploadPercent"
size="xs"
class="w-full"
role="progressbar"
:aria-valuenow="uploadPercent"
/>
</div>
<p v-if="!succeeded" class="text-sm text-toned">
Uploading {{ formatMegabytes(progress.loaded) }} of
{{ formatMegabytes(progress.total) }} MB
</p>
</div>
<div v-else-if="phase === 'rejected'" class="flex flex-col gap-1">
<div
class="flex flex-col items-center gap-3 rounded-md border border-error px-6 py-8 text-center"
>
<UIcon name="i-lucide-file-x" class="size-6 text-error" />
<p class="text-sm font-semibold text-highlighted">{{ rejectedTitle }}</p>
<p class="text-sm text-toned">{{ rejectedText }}</p>
</div>
</div>
<div v-else-if="phase === 'failed'" class="flex flex-col gap-1">
<p class="text-sm font-semibold text-highlighted">Upload failed</p>
<p class="text-sm text-toned">
The server didn't accept the file. Your article hasn't changed.
</p>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton v-if="showCancel" color="neutral" variant="outline" @click="cancel">
{{ cancelLabel }}
</UButton>
<UButton v-if="phase === 'rejected'" @click="chooseAnotherFile">
Choose another file
</UButton>
<UButton v-if="phase === 'failed'" @click="tryAgain"> Try again </UButton>
</div>
</template>
</UModal>
</template>
@@ -0,0 +1,397 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import ImageUploader from '../ImageUploader.vue'
import * as imageUpload from '@/lib/imageUpload'
vi.mock('@/lib/imageUpload', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageUpload')>()
return {
...actual,
validateImageFile: vi.fn(actual.validateImageFile),
}
})
class FakeXMLHttpRequest {
static instances: FakeXMLHttpRequest[] = []
method = ''
url = ''
status = 0
responseText = ''
withCredentials = false
sentBody: FormData | null = null
aborted = false
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.aborted = true
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))
}
fireProgress(loaded: number, total: number) {
this.uploadListeners.progress?.({ loaded, total })
}
resolve(status: number, body: string) {
this.status = status
this.responseText = body
this.dispatch('load')
}
fail() {
this.dispatch('error')
}
}
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
}
const Host = defineComponent({
components: { ImageUploader },
props: {
articleId: { type: Number, required: true },
initialFile: { type: Object as () => File | null, default: null },
},
emits: ['uploaded', 'close'],
setup(_props, { emit }) {
const open = ref(false)
return {
open,
onUploaded: (location: string) => emit('uploaded', location),
onClose: () => emit('close'),
}
},
template: `<ImageUploader v-model:open="open" :article-id="articleId" :initial-file="initialFile" @uploaded="onUploaded" @close="onClose" />`,
})
function mountUploader(props: { articleId?: number; initialFile?: File | null } = {}) {
return mount(Host, {
props: { articleId: 12, initialFile: null, ...props },
global: { plugins: [uiPlugin] },
attachTo: document.body,
})
}
async function openModal(wrapper: ReturnType<typeof mountUploader>) {
;(wrapper.vm as unknown as { open: boolean }).open = true
await nextTick()
await nextTick()
}
function fileInput() {
return document.body.querySelector<HTMLInputElement>('[data-testid="image-file-input"]')!
}
function dropZone() {
return document.body.querySelector<HTMLElement>('[data-testid="image-dropzone"]')!
}
async function chooseFile(file: File) {
const input = fileInput()
Object.defineProperty(input, 'files', {
value: { 0: file, length: 1 },
configurable: true,
})
input.dispatchEvent(new Event('change'))
await nextTick()
await nextTick()
}
function findButton(text: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
async function clickButton(text: string) {
findButton(text)?.click()
await nextTick()
await nextTick()
}
const RealXMLHttpRequest = global.XMLHttpRequest
beforeEach(() => {
FakeXMLHttpRequest.instances = []
global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest
})
afterEach(() => {
document.body.innerHTML = ''
global.XMLHttpRequest = RealXMLHttpRequest
})
describe('Opening the uploader from the toolbar image button shows the idle state', () => {
it('shows the idle copy and sends no request', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
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)
wrapper.unmount()
})
})
describe('Dragging a file over the drop zone shows the dragging-over state', () => {
it('shows "Drop to upload"', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
dropZone().dispatchEvent(new Event('dragover', { bubbles: true, cancelable: true }))
await nextTick()
expect(document.body.textContent).toContain('Drop to upload')
wrapper.unmount()
})
})
describe('Choosing a file the browser can reject never sends a request', () => {
it.each([
{
filename: 'plan.pdf',
contentType: 'application/pdf',
sizeMB: 1.2,
description: 'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
},
{
filename: 'disguised.png',
contentType: 'application/pdf',
sizeMB: 1.2,
description: 'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
},
{
filename: 'north-wall.tif',
contentType: 'image/png',
sizeMB: 14.2,
description: 'north-wall.tif is 14.2 MB. Images can be up to 10 MB.',
},
])(
'rejects $filename with the exact copy',
async ({ filename, contentType, sizeMB, description }) => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture(filename, contentType, sizeMB))
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
expect(document.body.textContent).toContain(`"${filename}" can't be used`)
expect(document.body.textContent).toContain(description)
expect(findButton('Choose another file')).toBeTruthy()
expect(findButton('Cancel')).toBeTruthy()
wrapper.unmount()
},
)
})
describe('"Choose another file" from the rejected state returns to idle', () => {
it('shows the idle state again', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('plan.pdf', 'application/pdf', 1.2))
await clickButton('Choose another file')
expect(document.body.textContent).toContain('Drop an image here')
wrapper.unmount()
})
})
describe('A valid upload shows progress and its cancel action reads "Cancel upload"', () => {
it('sends the file and reports live progress', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
const xhr = latestXHR()
expect(xhr.url).toBe('/v1/article/12/image')
expect(xhr.sentBody?.get('file')).toBeInstanceOf(File)
expect(document.body.textContent).toContain('north-wall.jpg 0%')
expect(document.body.textContent).toContain('Uploading 0.0 of 3.9 MB')
expect(findButton('Cancel upload')).toBeTruthy()
xhr.fireProgress(mib(2.4), mib(3.9))
await nextTick()
expect(document.body.textContent).toContain('north-wall.jpg 62%')
expect(document.body.textContent).toContain('Uploading 2.4 of 3.9 MB')
wrapper.unmount()
})
})
describe('Closing the dialog during an upload cancels it', () => {
it('aborts the in-flight XMLHttpRequest and closes the modal', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
await clickButton('Cancel upload')
expect(xhr.aborted).toBe(true)
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
wrapper.unmount()
})
})
describe('A successful upload emits the location and auto-closes 600 ms later', () => {
it('shows "Image added" then closes', async () => {
vi.useFakeTimers()
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
xhr.resolve(201, JSON.stringify({ location: '/static/uploadedImages/north-wall.jpg' }))
await vi.advanceTimersByTimeAsync(0)
expect(wrapper.emitted('uploaded')).toEqual([['/static/uploadedImages/north-wall.jpg']])
expect(document.body.textContent).toContain('Image added')
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
await vi.advanceTimersByTimeAsync(590)
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
await vi.advanceTimersByTimeAsync(10)
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
wrapper.unmount()
vi.useRealTimers()
})
})
describe('A server-side upload failure keeps the article unchanged and offers a retry', () => {
it.each([
{ name: 'a 500 response', trigger: (xhr: FakeXMLHttpRequest) => xhr.resolve(500, '') },
{ name: 'a network failure', trigger: (xhr: FakeXMLHttpRequest) => xhr.fail() },
])('shows the failed state for $name', async ({ trigger }) => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
trigger(xhr)
await flushPromises()
expect(document.body.textContent).toContain('Upload failed')
expect(document.body.textContent).toContain(
"The server didn't accept the file. Your article hasn't changed.",
)
expect(findButton('Try again')).toBeTruthy()
expect(wrapper.emitted('uploaded')).toBeFalsy()
wrapper.unmount()
})
})
describe('"Try again" re-attempts the upload with the same file', () => {
it('resends the same file under field "file"', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
latestXHR().resolve(500, '')
await flushPromises()
await clickButton('Try again')
expect(FakeXMLHttpRequest.instances).toHaveLength(2)
const xhr = latestXHR()
expect(xhr.url).toBe('/v1/article/12/image')
expect((xhr.sentBody?.get('file') as File).name).toBe('north-wall.jpg')
expect(document.body.textContent).toContain('north-wall.jpg 0%')
wrapper.unmount()
})
})
describe('A server 400 for a type the client should have caught shows the same rejected copy', () => {
it('shows the type-rejection copy', async () => {
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('notes.pdf', 'image/png', 1.2))
const xhr = latestXHR()
xhr.resolve(400, 'the file must be a PNG, JPEG, WEBP, or SVG image')
await flushPromises()
expect(document.body.textContent).toContain(`"notes.pdf" can't be used`)
expect(document.body.textContent).toContain(
'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
)
expect(findButton('Choose another file')).toBeTruthy()
expect(wrapper.emitted('uploaded')).toBeFalsy()
wrapper.unmount()
})
})
describe('A server 400 for a size the client should have caught shows the size-named copy', () => {
it('shows the size-rejection copy computed from the file', async () => {
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.tif', 'image/png', 14.2))
const xhr = latestXHR()
xhr.resolve(400, 'the file exceeds the maximum upload size')
await flushPromises()
expect(document.body.textContent).toContain(`"north-wall.tif" can't be used`)
expect(document.body.textContent).toContain(
'north-wall.tif is 14.2 MB. Images can be up to 10 MB.',
)
expect(findButton('Choose another file')).toBeTruthy()
expect(wrapper.emitted('uploaded')).toBeFalsy()
wrapper.unmount()
})
})
+59
View File
@@ -0,0 +1,59 @@
import { ref } from 'vue'
export interface UploadProgress {
loaded: number
total: number
}
export type UploadOutcome =
| { kind: 'success'; location: string }
| { kind: 'rejected'; body: string }
| { kind: 'failed' }
export function useImageUpload() {
const progress = ref<UploadProgress>({ loaded: 0, total: 0 })
let activeRequest: XMLHttpRequest | null = null
function abort() {
activeRequest?.abort()
activeRequest = null
}
function start(articleId: number, file: File): Promise<UploadOutcome> {
progress.value = { loaded: 0, total: file.size }
return new Promise((resolve) => {
const request = new XMLHttpRequest()
activeRequest = request
request.open('POST', `/v1/article/${articleId}/image`)
request.withCredentials = true
request.upload.addEventListener('progress', (event) => {
progress.value = { loaded: event.loaded, total: event.total }
})
request.addEventListener('load', () => {
activeRequest = null
if (request.status === 201) {
const body = JSON.parse(request.responseText) as { location: string }
resolve({ kind: 'success', location: body.location })
return
}
if (request.status === 400) {
resolve({ kind: 'rejected', body: request.responseText })
return
}
resolve({ kind: 'failed' })
})
request.addEventListener('error', () => {
activeRequest = null
resolve({ kind: 'failed' })
})
request.addEventListener('abort', () => {
activeRequest = null
})
const formData = new FormData()
formData.append('file', file)
request.send(formData)
})
}
return { progress, start, abort }
}
+44
View File
@@ -0,0 +1,44 @@
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024
export const ALLOWED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
export const ALLOWED_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.svg']
export const IMAGE_TYPE_REJECTION_TEXT = 'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.'
export type ImageRejectionKind = 'type' | 'size'
export type ImageValidationResult = { ok: true } | { ok: false; kind: ImageRejectionKind }
export function fileExtension(filename: string) {
const index = filename.lastIndexOf('.')
return index === -1 ? '' : filename.slice(index).toLowerCase()
}
export function validateImageFile(file: File): ImageValidationResult {
if (file.size > MAX_IMAGE_BYTES) {
return { ok: false, kind: 'size' }
}
const typeAllowed = ALLOWED_IMAGE_MIME_TYPES.includes(file.type)
const extensionAllowed = ALLOWED_IMAGE_EXTENSIONS.includes(fileExtension(file.name))
if (!typeAllowed || !extensionAllowed) {
return { ok: false, kind: 'type' }
}
return { ok: true }
}
export function formatMegabytes(bytes: number) {
return (bytes / (1024 * 1024)).toFixed(1)
}
export function isSizeRejectionBody(body: string) {
return body.toLowerCase().includes('exceed')
}
export function rejectionTitle(filename: string) {
return `"${filename}" can't be used`
}
export function sizeRejectionText(filename: string, bytes: number) {
return `${filename} is ${formatMegabytes(bytes)} MB. Images can be up to 10 MB.`
}
+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.',
})
})
})
+108
View File
@@ -205,6 +205,63 @@ async function typeExactBodyLength(page: Page, totalLength: number) {
await page.keyboard.insertText(inner)
}
function mib(value: number) {
return Math.round(value * 1024 * 1024)
}
async function openImageUploader(page: Page) {
await page.getByRole('button', { name: 'Image', exact: true }).first().click()
await page.getByRole('dialog').getByText('Add an image').waitFor()
}
async function chooseImageFile(page: Page, filename: string, mimeType: string, size: number) {
await page.locator('input[type="file"]').setInputFiles({
name: filename,
mimeType,
buffer: Buffer.alloc(size),
})
}
async function mockUploadedImage(page: Page) {
const isDark = await page.evaluate(
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
)
const background = isDark ? 'rgb(26,26,26)' : 'rgb(244,244,245)'
const border = isDark ? 'rgb(64,64,64)' : 'rgb(212,212,216)'
await page.route('**/static/uploadedImages/**', async (route) => {
const pathname = new URL(route.request().url()).pathname
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="450"><rect width="100%" height="100%" fill="${background}" stroke="${border}" stroke-width="2" /><text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle" font-family="monospace" font-size="16" fill="${border}">${pathname}</text></svg>`
await route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg })
})
}
async function freezeUploadProgress(page: Page, loaded: number, total: number) {
await page.evaluate(
({ loaded, total }) => {
const proto = XMLHttpRequest.prototype as unknown as Record<string, unknown>
const originalOpen = proto.open as (...args: unknown[]) => void
proto.open = function (this: XMLHttpRequest, ...args: unknown[]) {
;(this as unknown as { __imageUrl?: string }).__imageUrl = String(args[1])
return originalOpen.apply(this, args)
}
const originalSend = proto.send as (...args: unknown[]) => void
proto.send = function (this: XMLHttpRequest, ...args: unknown[]) {
const url = (this as unknown as { __imageUrl?: string }).__imageUrl ?? ''
if (url.includes('/image')) {
setTimeout(() => {
const event = new Event('progress')
Object.assign(event, { loaded, total, lengthComputable: true })
this.upload.dispatchEvent(event)
}, 50)
return
}
return originalSend.apply(this, args)
}
},
{ loaded, total },
)
}
const newArticleTitle = 'Choosing tiles for a small bathroom'
const newArticleBody = 'Small rooms show every grout line, so we start with the floor.'
@@ -846,6 +903,57 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'GET /v1/article/9999': { status: 404 },
},
},
'Upload: choose': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await openImageUploader(page)
},
},
'Upload: in progress': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await openImageUploader(page)
await freezeUploadProgress(page, mib(2.4), mib(3.9))
await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9))
await page.getByText('north-wall.jpg 62%').waitFor()
},
},
'Upload: rejected': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await openImageUploader(page)
await chooseImageFile(page, 'north-wall.tif', 'image/png', mib(14.2))
await page.getByText(`"north-wall.tif" can't be used`).waitFor()
},
},
'Upload: inserted': {
path: '/article/12/edit',
now: editorNow,
api: editorApi({
'POST /v1/article/12/image': {
status: 201,
body: { location: '/static/uploadedImages/north-wall.jpg' },
},
}),
steps: async (page) => {
await mockUploadedImage(page)
await page.getByText('add a margin on materials').click()
await page.keyboard.press('Home')
await openImageUploader(page)
await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9))
await page.getByText('Save to keep it in the article.').first().waitFor()
await page.waitForTimeout(650)
},
},
},
'new-article': {
Default: {
+1
View File
@@ -15,6 +15,7 @@ export default defineConfig({
proxy: {
'/v1': 'http://localhost:8080',
'/healthz': 'http://localhost:8080',
'/static': 'http://localhost:8080',
},
},
plugins: [