feat: image upload on articles
This commit is contained in:
1 parent
d4fcfb2bba
commit
938263cd6a
12 files changed
+1436
-65
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user