feat: media picker and image removal
This commit is contained in:
1 parent
008b0dfbb9
commit
b3793d5dbb
54 files changed
+3769
-1412
No files matched your search
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useToast } from '@nuxt/ui/composables'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { bodyImageCount } from '@/lib/bodyImageIds'
|
||||
|
||||
const props = defineProps<{ article: Article }>()
|
||||
|
||||
@@ -50,7 +51,7 @@ function formatDetailDate(iso: string, relative = false) {
|
||||
const createdLabel = computed(() => formatDetailDate(props.article.created_at))
|
||||
const updatedLabel = computed(() => formatDetailDate(props.article.updated_at, true))
|
||||
const publicUrl = computed(() => `/v1/article/${props.article.id}`)
|
||||
const imagesCount = computed(() => props.article.images.length)
|
||||
const imagesCount = computed(() => bodyImageCount(props.article.body))
|
||||
const apiNote = computed(() =>
|
||||
props.article.is_online
|
||||
? 'Live. Your website can read this article.'
|
||||
|
||||
@@ -208,6 +208,18 @@ defineExpose({
|
||||
insertImage(src: string, alt: string, imageId: number) {
|
||||
editorInstance.value?.commands.setArticleImage({ src, alt, imageId })
|
||||
},
|
||||
imageSrc(imageId: number): string | null {
|
||||
const editor = editorInstance.value
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
const pos = findArticleImagePos(editor, imageId)
|
||||
if (pos === null) {
|
||||
return null
|
||||
}
|
||||
const src = editor.state.doc.nodeAt(pos)?.attrs.src
|
||||
return typeof src === 'string' ? src : null
|
||||
},
|
||||
removeImage(imageId: number) {
|
||||
const editor = editorInstance.value
|
||||
if (!editor) {
|
||||
|
||||
@@ -41,13 +41,14 @@ function onDeleteClick() {
|
||||
v-if="showDeleteButton"
|
||||
data-testid="remove-image-button"
|
||||
icon="i-lucide-trash-2"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
class="absolute top-1 right-1 bg-default"
|
||||
aria-label="Remove image"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="absolute top-2 right-2 h-7 gap-1.5 border border-accented bg-default px-2.5 text-xs font-medium text-highlighted ring-0"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@mousedown.prevent
|
||||
@click="onDeleteClick"
|
||||
/>
|
||||
>
|
||||
Remove image
|
||||
</UButton>
|
||||
</NodeViewWrapper>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -11,6 +12,7 @@ const props = withDefaults(
|
||||
tone: 'danger' | 'neutral'
|
||||
action: () => Promise<void | boolean>
|
||||
dismissResolvesTo?: 'cancel' | 'confirm'
|
||||
preview?: { src: string; filename?: string; size?: string }
|
||||
}>(),
|
||||
{
|
||||
dismissResolvesTo: 'cancel',
|
||||
@@ -25,6 +27,14 @@ const error = ref('')
|
||||
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
let settled = false
|
||||
|
||||
const previewDetail = computed(() =>
|
||||
props.preview?.size ? `Stays in the library · ${props.preview.size}` : 'Stays in the library',
|
||||
)
|
||||
const previewThumb = computed(() => ({
|
||||
url: props.preview?.src ?? '',
|
||||
content_type: '',
|
||||
sha256: '',
|
||||
}))
|
||||
const confirmColor = computed(() => (props.tone === 'danger' ? 'error' : 'primary'))
|
||||
const confirmText = computed(() => (pending.value ? props.pendingLabel : props.confirmLabel))
|
||||
|
||||
@@ -107,6 +117,25 @@ function onCancel() {
|
||||
role="alert"
|
||||
:ui="{ root: 'gap-2 px-3 py-2.5', description: 'text-sm text-toned' }"
|
||||
/>
|
||||
<div
|
||||
v-if="preview"
|
||||
data-testid="confirm-preview"
|
||||
class="flex items-center gap-3 border border-default px-3 py-2.5"
|
||||
>
|
||||
<MediaThumb
|
||||
:item="previewThumb"
|
||||
size="sm"
|
||||
class="h-10.5 w-14 border border-default"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span
|
||||
v-if="preview.filename"
|
||||
class="truncate font-medium text-highlighted"
|
||||
>{{ preview.filename }}</span
|
||||
>
|
||||
<span class="text-xs/5 text-muted">{{ previewDetail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="body" :pending="pending" :confirm="onConfirm" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import MediaPicker from '@/components/MediaPicker.vue'
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import { useLibraryList } from '@/composables/useLibraryList'
|
||||
import { classifyMediaError, type MediaItem } from '@/composables/useMedia'
|
||||
import { oldestOf } from '@/composables/useMediaUpload'
|
||||
import { hashFile } from '@/lib/fileHash'
|
||||
import {
|
||||
IMAGE_TYPE_REJECTION_TEXT,
|
||||
formatUploadProgress,
|
||||
isSizeRejectionBody,
|
||||
rejectionTitle,
|
||||
sizeRejectionText,
|
||||
validateImageFile,
|
||||
} from '@/lib/imageUpload'
|
||||
import { libraryMatchText } from '@/lib/mediaFormat'
|
||||
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
|
||||
import {
|
||||
ASPECT_RATIOS,
|
||||
WIDTH_PRESETS,
|
||||
@@ -35,17 +43,38 @@ import {
|
||||
type Corner = 'tl' | 'tr' | 'bl' | 'br'
|
||||
import { useImageUpload } from '@/composables/useImageUpload'
|
||||
|
||||
const props = defineProps<{
|
||||
articleId: number
|
||||
initialFile?: File | null
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
initialFile?: File | null
|
||||
inArticleIds?: number[]
|
||||
}>(),
|
||||
{ initialFile: null, inArticleIds: () => [] },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploaded: [location: string, imageId: number]
|
||||
uploaded: [item: MediaItem]
|
||||
inserted: [item: MediaItem, alt: string]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
type Phase = 'idle' | 'dragging-over' | 'crop' | 'uploading' | 'rejected' | 'failed'
|
||||
type Phase = 'idle' | 'dragging-over' | 'duplicate' | 'crop' | 'uploading' | 'rejected' | 'failed'
|
||||
type Tab = 'upload' | 'library'
|
||||
|
||||
const DROP_ZONE_UI = {
|
||||
base: 'min-h-45 rounded-none border-accented p-4 text-sm',
|
||||
wrapper: 'gap-2 p-0',
|
||||
avatar: 'size-6 bg-transparent',
|
||||
icon: 'size-6 text-toned',
|
||||
label: 'mt-0 font-medium text-highlighted',
|
||||
description: 'mt-0 max-w-75 text-xs/5 text-toned',
|
||||
}
|
||||
|
||||
const FOOTER_BUTTON_CLASS = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
|
||||
|
||||
const TAB_ITEMS = [
|
||||
{ label: 'Upload', value: 'upload', icon: 'i-lucide-upload' },
|
||||
{ label: 'Library', value: 'library', icon: 'i-lucide-images' },
|
||||
]
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
@@ -55,6 +84,11 @@ const lastUploadedFile = ref<File | null>(null)
|
||||
const pickedFile = ref<File | null>(null)
|
||||
const rejectionKind = ref<'type' | 'size' | null>(null)
|
||||
const succeeded = ref(false)
|
||||
const tab = ref<Tab>('library')
|
||||
const existing = ref<MediaItem | null>(null)
|
||||
const selected = ref<MediaItem | null>(null)
|
||||
const alt = ref('')
|
||||
let tabTouched = false
|
||||
|
||||
const naturalWidth = ref(0)
|
||||
const naturalHeight = ref(0)
|
||||
@@ -67,6 +101,8 @@ const previewCanvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const sourceBitmap = ref<ImageBitmap | null>(null)
|
||||
|
||||
const { progress, start, abort } = useImageUpload()
|
||||
const { request } = useApi()
|
||||
const library = useLibraryList()
|
||||
let closeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const uploadPercent = computed(() => {
|
||||
@@ -90,15 +126,43 @@ const rejectedText = computed(() => {
|
||||
return IMAGE_TYPE_REJECTION_TEXT
|
||||
})
|
||||
|
||||
const onLibraryTab = computed(() => tab.value === 'library')
|
||||
|
||||
const showTabs = computed(
|
||||
() =>
|
||||
onLibraryTab.value ||
|
||||
phase.value === 'idle' ||
|
||||
phase.value === 'dragging-over' ||
|
||||
phase.value === 'duplicate',
|
||||
)
|
||||
|
||||
const tabModel = computed({
|
||||
get: () => tab.value,
|
||||
set: (value: string) => selectTab(value === 'library' ? 'library' : 'upload'),
|
||||
})
|
||||
|
||||
const duplicateText = computed(() =>
|
||||
existing.value && chosenFile.value
|
||||
? libraryMatchText(chosenFile.value.name, existing.value)
|
||||
: '',
|
||||
)
|
||||
|
||||
const cancelLabel = computed(() => (phase.value === 'uploading' ? 'Cancel upload' : 'Cancel'))
|
||||
|
||||
const showCancel = computed(
|
||||
() =>
|
||||
phase.value !== 'failed' &&
|
||||
phase.value !== 'crop' &&
|
||||
!(phase.value === 'uploading' && succeeded.value),
|
||||
onLibraryTab.value ||
|
||||
(phase.value !== 'failed' &&
|
||||
phase.value !== 'crop' &&
|
||||
!(phase.value === 'uploading' && succeeded.value)),
|
||||
)
|
||||
|
||||
const showFailedActions = computed(() => !onLibraryTab.value && phase.value === 'failed')
|
||||
const showRejectedActions = computed(() => !onLibraryTab.value && phase.value === 'rejected')
|
||||
const showCropActions = computed(() => !onLibraryTab.value && phase.value === 'crop')
|
||||
|
||||
const wideModal = computed(() => showTabs.value || phase.value === 'crop')
|
||||
|
||||
const modalTitle = computed(() => (phase.value === 'crop' ? 'Crop and resize' : 'Add an image'))
|
||||
|
||||
const modalDescription = computed(() => {
|
||||
@@ -110,11 +174,13 @@ const modalDescription = computed(() => {
|
||||
|
||||
const modalContentClass = computed(
|
||||
() =>
|
||||
`divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 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 ${
|
||||
phase.value === 'crop' ? 'sm:max-w-160' : 'sm:max-w-120'
|
||||
}`,
|
||||
`divide-y-0 shadow-(--shadow-modal) ring-0 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 ${
|
||||
showTabs.value ? 'sm:top-10' : 'sm:top-35'
|
||||
} ${wideModal.value ? 'sm:max-w-160' : 'sm:max-w-120'}`,
|
||||
)
|
||||
|
||||
const bodyClass = computed(() => (showTabs.value ? 'px-6 pt-4' : 'px-6 pt-5'))
|
||||
|
||||
const boundsWidth = computed(
|
||||
() => rotatedDimensions(naturalWidth.value, naturalHeight.value, rotation.value).width,
|
||||
)
|
||||
@@ -164,20 +230,24 @@ function clearCloseTimer() {
|
||||
|
||||
async function runUpload(file: File) {
|
||||
lastUploadedFile.value = file
|
||||
const outcome = await start(props.articleId, file)
|
||||
const outcome = await start(file)
|
||||
if (outcome.kind === 'success') {
|
||||
succeeded.value = true
|
||||
emit('uploaded', outcome.location, outcome.id)
|
||||
emit('uploaded', outcome.item)
|
||||
closeTimer = setTimeout(() => {
|
||||
open.value = false
|
||||
}, 600)
|
||||
return
|
||||
}
|
||||
if (outcome.kind === 'rejected') {
|
||||
rejectionKind.value = isSizeRejectionBody(outcome.body) ? 'size' : 'type'
|
||||
rejectionKind.value = outcome.reason
|
||||
phase.value = 'rejected'
|
||||
return
|
||||
}
|
||||
if (outcome.kind === 'handled') {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
phase.value = 'failed'
|
||||
}
|
||||
|
||||
@@ -244,7 +314,32 @@ async function openCropStep(file: File) {
|
||||
await loadPreviewBitmap(file)
|
||||
}
|
||||
|
||||
function handleFile(file: File) {
|
||||
async function findExisting(file: File): Promise<MediaItem | null | 'handled'> {
|
||||
const hash = await hashFile(file)
|
||||
if (hash === null) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const response = await request<{ items: MediaItem[] }>(`/v1/media/?hash=${hash}`)
|
||||
return oldestOf(response.items) ?? null
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && classifyMediaError(error) === 'handled') {
|
||||
return 'handled'
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function continueWithFile(file: File) {
|
||||
if (file.type === 'image/svg+xml') {
|
||||
phase.value = 'uploading'
|
||||
runUpload(file)
|
||||
return
|
||||
}
|
||||
openCropStep(file)
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
chosenFile.value = file
|
||||
const result = validateImageFile(file)
|
||||
if (!result.ok) {
|
||||
@@ -254,12 +349,75 @@ function handleFile(file: File) {
|
||||
}
|
||||
rejectionKind.value = null
|
||||
succeeded.value = false
|
||||
if (file.type === 'image/svg+xml') {
|
||||
phase.value = 'uploading'
|
||||
runUpload(file)
|
||||
const match = await findExisting(file)
|
||||
if (!open.value || chosenFile.value !== file) {
|
||||
return
|
||||
}
|
||||
openCropStep(file)
|
||||
if (match === 'handled') {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
tab.value = 'upload'
|
||||
if (match) {
|
||||
existing.value = match
|
||||
phase.value = 'duplicate'
|
||||
return
|
||||
}
|
||||
continueWithFile(file)
|
||||
}
|
||||
|
||||
function uploadAnyway() {
|
||||
if (chosenFile.value) {
|
||||
existing.value = null
|
||||
continueWithFile(chosenFile.value)
|
||||
}
|
||||
}
|
||||
|
||||
function selectTab(next: Tab) {
|
||||
tabTouched = true
|
||||
tab.value = next
|
||||
if (next === 'library') {
|
||||
library.ensureLoaded()
|
||||
}
|
||||
}
|
||||
|
||||
function useExisting() {
|
||||
if (!existing.value) {
|
||||
return
|
||||
}
|
||||
selectItem(existing.value)
|
||||
selectTab('library')
|
||||
phase.value = 'idle'
|
||||
existing.value = null
|
||||
}
|
||||
|
||||
function selectItem(item: MediaItem) {
|
||||
if (selected.value?.id === item.id) {
|
||||
return
|
||||
}
|
||||
selected.value = item
|
||||
alt.value = ''
|
||||
}
|
||||
|
||||
async function searchLibrary(query: string) {
|
||||
selected.value = null
|
||||
alt.value = ''
|
||||
await library.search(query)
|
||||
}
|
||||
|
||||
function insertSelected() {
|
||||
if (!selected.value) {
|
||||
return
|
||||
}
|
||||
emit('inserted', selected.value, alt.value)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
async function openLibraryFirst() {
|
||||
await library.load()
|
||||
if (!tabTouched && library.status.value === 'ready' && library.items.value.length === 0) {
|
||||
tab.value = 'upload'
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange() {
|
||||
@@ -291,6 +449,7 @@ function chooseAnotherFile() {
|
||||
chosenFile.value = null
|
||||
pickedFile.value = null
|
||||
rejectionKind.value = null
|
||||
existing.value = null
|
||||
closeSourceBitmap()
|
||||
}
|
||||
|
||||
@@ -517,9 +676,18 @@ watch(open, (isOpen) => {
|
||||
pickedFile.value = null
|
||||
rejectionKind.value = null
|
||||
succeeded.value = false
|
||||
existing.value = null
|
||||
selected.value = null
|
||||
alt.value = ''
|
||||
tabTouched = false
|
||||
library.reset()
|
||||
if (props.initialFile) {
|
||||
tab.value = 'upload'
|
||||
handleFile(props.initialFile)
|
||||
return
|
||||
}
|
||||
tab.value = 'library'
|
||||
openLibraryFirst()
|
||||
return
|
||||
}
|
||||
if (phase.value === 'uploading' && !succeeded.value) {
|
||||
@@ -542,262 +710,375 @@ onUnmounted(() => {
|
||||
:description="modalDescription"
|
||||
:ui="{
|
||||
content: modalContentClass,
|
||||
header: 'min-h-12 items-start px-6 pt-5 pb-0 sm:px-6',
|
||||
close: 'top-5 end-6',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
body: 'p-0 sm:p-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 v-if="showTabs" class="mt-4 border-b border-default px-6">
|
||||
<UTabs
|
||||
v-model="tabModel"
|
||||
:items="TAB_ITEMS"
|
||||
variant="link"
|
||||
color="neutral"
|
||||
:content="false"
|
||||
class="-mb-px"
|
||||
:ui="{
|
||||
list: 'gap-5 border-b-0 p-0',
|
||||
trigger: `${UNDERLINE_TAB_TRIGGER} max-md:text-sm`,
|
||||
leadingIcon: 'size-3.5',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div :class="bodyClass">
|
||||
<MediaPicker
|
||||
v-if="onLibraryTab"
|
||||
v-model:alt="alt"
|
||||
:items="library.items.value"
|
||||
:status="library.status.value"
|
||||
:show-skeleton="library.showSkeleton.value"
|
||||
:loading-more="library.loadingMore.value"
|
||||
:has-more="library.nextCursor.value !== null"
|
||||
:total="library.total.value"
|
||||
:query="library.query.value"
|
||||
:first-new-id="library.firstNewId.value"
|
||||
:in-article-ids="inArticleIds"
|
||||
:selected="selected"
|
||||
@search="searchLibrary"
|
||||
@select="selectItem"
|
||||
@load-more="library.loadMore"
|
||||
@retry="library.load()"
|
||||
@upload="selectTab('upload')"
|
||||
/>
|
||||
|
||||
<div v-else-if="phase === 'crop'" class="flex flex-col gap-4">
|
||||
<div
|
||||
class="flex h-60 items-center justify-center bg-elevated p-4 sm:h-85"
|
||||
data-testid="crop-stage"
|
||||
v-else-if="phase === 'idle' || phase === 'dragging-over'"
|
||||
data-testid="image-dropzone"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDropOnZone"
|
||||
>
|
||||
<div
|
||||
ref="imageBoxRef"
|
||||
class="relative h-full max-w-full"
|
||||
:style="{ aspectRatio: `${boundsWidth} / ${boundsHeight}` }"
|
||||
:aria-label="`${chosenFile?.name}, ${boundsWidth} × ${boundsHeight} px`"
|
||||
role="img"
|
||||
<UFileUpload
|
||||
v-model="pickedFile"
|
||||
data-testid="image-file-input"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
:multiple="false"
|
||||
layout="list"
|
||||
icon="i-lucide-image-plus"
|
||||
:reset="true"
|
||||
:ui="DROP_ZONE_UI"
|
||||
@change="onFileChange"
|
||||
>
|
||||
<div class="absolute inset-0 overflow-hidden bg-(--color-sand-200)">
|
||||
<canvas ref="previewCanvasRef" class="absolute inset-0 h-full w-full" />
|
||||
<template #label>
|
||||
{{
|
||||
phase === 'dragging-over'
|
||||
? 'Drop to upload'
|
||||
: 'Drop an image here, or choose a file'
|
||||
}}
|
||||
</template>
|
||||
<template #description>PNG, JPEG, WEBP, SVG · max 10 MB</template>
|
||||
</UFileUpload>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="phase === 'duplicate' && existing"
|
||||
data-testid="duplicate-card"
|
||||
class="flex flex-col gap-3 border border-info bg-default p-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<MediaThumb
|
||||
:item="existing"
|
||||
size="sm"
|
||||
class="h-13.5 w-18 border border-default"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<p class="font-semibold text-highlighted">Already in your library</p>
|
||||
<p class="text-xs text-toned">{{ duplicateText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 md:flex-row">
|
||||
<UButton class="h-11 justify-center md:h-8" @click="useExisting">
|
||||
Use existing
|
||||
</UButton>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 justify-center border border-accented bg-default text-highlighted ring-0 md:h-8"
|
||||
@click="uploadAnyway"
|
||||
>
|
||||
Upload anyway
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="phase === 'crop'" class="flex flex-col gap-4">
|
||||
<div
|
||||
class="flex h-60 items-center justify-center bg-elevated p-4 sm:h-85"
|
||||
data-testid="crop-stage"
|
||||
>
|
||||
<div
|
||||
ref="imageBoxRef"
|
||||
class="relative h-full max-w-full"
|
||||
:style="{ aspectRatio: `${boundsWidth} / ${boundsHeight}` }"
|
||||
:aria-label="`${chosenFile?.name}, ${boundsWidth} × ${boundsHeight} px`"
|
||||
role="img"
|
||||
>
|
||||
<div class="absolute inset-0 overflow-hidden bg-(--color-sand-200)">
|
||||
<canvas
|
||||
ref="previewCanvasRef"
|
||||
class="absolute inset-0 h-full w-full"
|
||||
/>
|
||||
<div
|
||||
class="absolute"
|
||||
:style="{
|
||||
left: `${(rect.x / boundsWidth) * 100}%`,
|
||||
top: `${(rect.y / boundsHeight) * 100}%`,
|
||||
width: `${(rect.width / boundsWidth) * 100}%`,
|
||||
height: `${(rect.height / boundsHeight) * 100}%`,
|
||||
boxShadow: '0 0 0 2000px rgb(10 10 10 / 0.55)',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="absolute"
|
||||
data-testid="crop-frame"
|
||||
tabindex="0"
|
||||
role="group"
|
||||
aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it."
|
||||
class="absolute cursor-move touch-none outline outline-white"
|
||||
:style="{
|
||||
left: `${(rect.x / boundsWidth) * 100}%`,
|
||||
top: `${(rect.y / boundsHeight) * 100}%`,
|
||||
width: `${(rect.width / boundsWidth) * 100}%`,
|
||||
height: `${(rect.height / boundsHeight) * 100}%`,
|
||||
boxShadow: '0 0 0 2000px rgb(10 10 10 / 0.55)',
|
||||
}"
|
||||
/>
|
||||
@keydown="onFrameKeydown"
|
||||
@mousedown="onFrameDragStart"
|
||||
@touchstart="onFrameDragStart"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 bottom-0 w-px bg-white/45"
|
||||
style="left: 33.333%"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 bottom-0 w-px bg-white/45"
|
||||
style="left: 66.666%"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 right-0 h-px bg-white/45"
|
||||
style="top: 33.333%"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 right-0 h-px bg-white/45"
|
||||
style="top: 66.666%"
|
||||
/>
|
||||
<div
|
||||
v-for="corner in ['tl', 'tr', 'bl', 'br'] as Corner[]"
|
||||
:key="corner"
|
||||
data-testid="crop-handle"
|
||||
class="absolute size-3 touch-none border border-ink-900 bg-white"
|
||||
:class="{
|
||||
'-top-1.5 -left-1.5 cursor-nwse-resize': corner === 'tl',
|
||||
'-top-1.5 -right-1.5 cursor-nesw-resize': corner === 'tr',
|
||||
'-bottom-1.5 -left-1.5 cursor-nesw-resize': corner === 'bl',
|
||||
'-bottom-1.5 -right-1.5 cursor-nwse-resize':
|
||||
corner === 'br',
|
||||
}"
|
||||
@mousedown="onHandleDragStart($event, corner)"
|
||||
@touchstart="onHandleDragStart($event, corner)"
|
||||
/>
|
||||
<span
|
||||
class="absolute bottom-2 left-2 bg-ink-900/80 px-1.5 py-px font-mono text-xs whitespace-nowrap text-white"
|
||||
>
|
||||
{{ dimsLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="flex-1 font-medium text-highlighted">Aspect ratio</span>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="i-lucide-rotate-ccw-square"
|
||||
@click="rotateImage"
|
||||
>
|
||||
Rotate
|
||||
</UButton>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="i-lucide-undo-2"
|
||||
@click="resetCrop"
|
||||
>
|
||||
Reset
|
||||
</UButton>
|
||||
</div>
|
||||
<div
|
||||
data-testid="crop-frame"
|
||||
tabindex="0"
|
||||
role="group"
|
||||
aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it."
|
||||
class="absolute cursor-move touch-none outline outline-white"
|
||||
:style="{
|
||||
left: `${(rect.x / boundsWidth) * 100}%`,
|
||||
top: `${(rect.y / boundsHeight) * 100}%`,
|
||||
width: `${(rect.width / boundsWidth) * 100}%`,
|
||||
height: `${(rect.height / boundsHeight) * 100}%`,
|
||||
}"
|
||||
@keydown="onFrameKeydown"
|
||||
@mousedown="onFrameDragStart"
|
||||
@touchstart="onFrameDragStart"
|
||||
role="radiogroup"
|
||||
aria-label="Aspect ratio"
|
||||
class="flex w-full border border-accented sm:w-max"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 bottom-0 w-px bg-white/45"
|
||||
style="left: 33.333%"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 bottom-0 w-px bg-white/45"
|
||||
style="left: 66.666%"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 right-0 h-px bg-white/45"
|
||||
style="top: 33.333%"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 right-0 h-px bg-white/45"
|
||||
style="top: 66.666%"
|
||||
/>
|
||||
<div
|
||||
v-for="corner in ['tl', 'tr', 'bl', 'br'] as Corner[]"
|
||||
:key="corner"
|
||||
data-testid="crop-handle"
|
||||
class="absolute size-3 touch-none border border-ink-900 bg-white"
|
||||
:class="{
|
||||
'-top-1.5 -left-1.5 cursor-nwse-resize': corner === 'tl',
|
||||
'-top-1.5 -right-1.5 cursor-nesw-resize': corner === 'tr',
|
||||
'-bottom-1.5 -left-1.5 cursor-nesw-resize': corner === 'bl',
|
||||
'-bottom-1.5 -right-1.5 cursor-nwse-resize': corner === 'br',
|
||||
}"
|
||||
@mousedown="onHandleDragStart($event, corner)"
|
||||
@touchstart="onHandleDragStart($event, corner)"
|
||||
/>
|
||||
<span
|
||||
class="absolute bottom-2 left-2 bg-ink-900/80 px-1.5 py-px font-mono text-xs whitespace-nowrap text-white"
|
||||
<button
|
||||
v-for="ratio in ASPECT_RATIOS"
|
||||
:key="ratio"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="ratioLabel === ratio"
|
||||
:tabindex="ratioLabel === ratio ? 0 : -1"
|
||||
class="flex-1 px-3 py-1.5 font-medium whitespace-nowrap sm:flex-none"
|
||||
:class="
|
||||
ratioLabel === ratio
|
||||
? 'bg-primary text-inverted'
|
||||
: 'bg-transparent text-toned'
|
||||
"
|
||||
@click="selectRatio(ratio)"
|
||||
>
|
||||
{{ dimsLabel }}
|
||||
</span>
|
||||
{{ ratio }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="flex-1 font-medium text-highlighted">Aspect ratio</span>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="i-lucide-rotate-ccw-square"
|
||||
@click="rotateImage"
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-medium text-highlighted">Width</span>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Width"
|
||||
class="flex w-full border border-accented sm:w-max"
|
||||
>
|
||||
Rotate
|
||||
</UButton>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="i-lucide-undo-2"
|
||||
@click="resetCrop"
|
||||
>
|
||||
Reset
|
||||
</UButton>
|
||||
<button
|
||||
v-for="widthOption in WIDTH_PRESETS"
|
||||
:key="widthOption"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="widthLabel === widthOption"
|
||||
:tabindex="widthLabel === widthOption ? 0 : -1"
|
||||
class="flex-1 px-3 py-1.5 font-medium whitespace-nowrap sm:flex-none"
|
||||
:class="
|
||||
widthLabel === widthOption
|
||||
? 'bg-primary text-inverted'
|
||||
: 'bg-transparent text-toned'
|
||||
"
|
||||
@click="selectWidth(widthOption)"
|
||||
>
|
||||
{{ widthOption }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-muted">{{ monoLine }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Aspect ratio"
|
||||
class="flex w-full border border-accented sm:w-max"
|
||||
v-if="showNarrowNote"
|
||||
role="status"
|
||||
class="flex gap-2 bg-(--rc-info-soft) p-2.5"
|
||||
>
|
||||
<button
|
||||
v-for="ratio in ASPECT_RATIOS"
|
||||
:key="ratio"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="ratioLabel === ratio"
|
||||
:tabindex="ratioLabel === ratio ? 0 : -1"
|
||||
class="flex-1 px-3 py-1.5 font-medium whitespace-nowrap sm:flex-none"
|
||||
:class="
|
||||
ratioLabel === ratio
|
||||
? 'bg-primary text-inverted'
|
||||
: 'bg-transparent text-toned'
|
||||
"
|
||||
@click="selectRatio(ratio)"
|
||||
>
|
||||
{{ ratio }}
|
||||
</button>
|
||||
<UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0 text-info" />
|
||||
<span class="text-toned">{{ narrowNoteText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-medium text-highlighted">Width</span>
|
||||
<div v-else-if="phase === 'uploading'" class="flex flex-col gap-3">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Width"
|
||||
class="flex w-full border border-accented sm:w-max"
|
||||
class="flex flex-col items-center gap-3 rounded-md border border-default px-6 py-8 text-center"
|
||||
>
|
||||
<button
|
||||
v-for="widthOption in WIDTH_PRESETS"
|
||||
:key="widthOption"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="widthLabel === widthOption"
|
||||
:tabindex="widthLabel === widthOption ? 0 : -1"
|
||||
class="flex-1 px-3 py-1.5 font-medium whitespace-nowrap sm:flex-none"
|
||||
:class="
|
||||
widthLabel === widthOption
|
||||
? 'bg-primary text-inverted'
|
||||
: 'bg-transparent text-toned'
|
||||
"
|
||||
@click="selectWidth(widthOption)"
|
||||
>
|
||||
{{ widthOption }}
|
||||
</button>
|
||||
<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>
|
||||
<span class="font-mono text-xs text-muted">{{ monoLine }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showNarrowNote"
|
||||
role="status"
|
||||
class="flex gap-2 bg-(--rc-info-soft) p-2.5"
|
||||
>
|
||||
<UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0 text-info" />
|
||||
<span class="text-toned">{{ narrowNoteText }}</span>
|
||||
</div>
|
||||
</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 v-if="!succeeded" class="text-sm text-toned">
|
||||
{{ formatUploadProgress(progress.loaded, progress.total) }}
|
||||
</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">
|
||||
{{ formatUploadProgress(progress.loaded, progress.total) }}
|
||||
</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 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>
|
||||
|
||||
<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 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>
|
||||
</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">
|
||||
<UButton
|
||||
v-if="showCancel"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
:class="FOOTER_BUTTON_CLASS"
|
||||
class="text-highlighted"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ cancelLabel }}
|
||||
</UButton>
|
||||
<UButton v-if="phase === 'rejected'" @click="chooseAnotherFile">
|
||||
<UButton
|
||||
v-if="onLibraryTab"
|
||||
block
|
||||
:class="FOOTER_BUTTON_CLASS"
|
||||
:disabled="!selected"
|
||||
@click="insertSelected"
|
||||
>
|
||||
Insert image
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="showRejectedActions"
|
||||
block
|
||||
:class="FOOTER_BUTTON_CLASS"
|
||||
@click="chooseAnotherFile"
|
||||
>
|
||||
Choose another file
|
||||
</UButton>
|
||||
<UButton v-if="phase === 'failed'" @click="tryAgain"> Try again </UButton>
|
||||
<template v-if="phase === 'crop'">
|
||||
<UButton color="neutral" variant="outline" @click="chooseAnotherFile">
|
||||
<UButton
|
||||
v-if="showFailedActions"
|
||||
block
|
||||
:class="FOOTER_BUTTON_CLASS"
|
||||
@click="tryAgain"
|
||||
>
|
||||
Try again
|
||||
</UButton>
|
||||
<template v-if="showCropActions">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
:class="FOOTER_BUTTON_CLASS"
|
||||
class="text-highlighted"
|
||||
@click="chooseAnotherFile"
|
||||
>
|
||||
Choose another file
|
||||
</UButton>
|
||||
<UButton @click="confirmCropUpload">Upload image</UButton>
|
||||
<UButton block :class="FOOTER_BUTTON_CLASS" @click="confirmCropUpload">
|
||||
Upload image
|
||||
</UButton>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
import type { LibraryStatus } from '@/composables/useLibraryList'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 120
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const FIRST_LOAD_SKELETONS_DESKTOP = 8
|
||||
const FIRST_LOAD_SKELETONS_MOBILE = 9
|
||||
const COLUMNS_DESKTOP = 4
|
||||
const COLUMNS_MOBILE = 3
|
||||
const SEARCH_ICON_SIZE = 'size-3.5'
|
||||
const EMPTY_STATE_UI = {
|
||||
root: 'gap-3 border border-default bg-default px-6 py-8',
|
||||
header: 'gap-3',
|
||||
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
|
||||
title: 'text-[0.9375rem] font-semibold',
|
||||
description: 'max-w-80 text-muted text-pretty',
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
items: MediaItem[]
|
||||
status: LibraryStatus
|
||||
showSkeleton: boolean
|
||||
loadingMore: boolean
|
||||
hasMore: boolean
|
||||
total: number
|
||||
query: string
|
||||
firstNewId: number | null
|
||||
inArticleIds: number[]
|
||||
selected: MediaItem | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: [query: string]
|
||||
select: [item: MediaItem]
|
||||
'load-more': []
|
||||
retry: []
|
||||
upload: []
|
||||
}>()
|
||||
|
||||
const alt = defineModel<string>('alt', { default: '' })
|
||||
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const grid = ref<HTMLElement | null>(null)
|
||||
const searchText = ref(props.query)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const columns = computed(() => (isDesktop.value ? COLUMNS_DESKTOP : COLUMNS_MOBILE))
|
||||
const firstLoadSkeletons = computed(() =>
|
||||
isDesktop.value ? FIRST_LOAD_SKELETONS_DESKTOP : FIRST_LOAD_SKELETONS_MOBILE,
|
||||
)
|
||||
const searching = computed(() => props.query !== '')
|
||||
const summary = computed(() =>
|
||||
searching.value ? 'Newest first' : `Newest first · ${props.total} images`,
|
||||
)
|
||||
const scrollHeight = computed(() => (props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'))
|
||||
const showEmptyLibrary = computed(
|
||||
() => props.status === 'ready' && props.items.length === 0 && !searching.value,
|
||||
)
|
||||
const showNoResults = computed(
|
||||
() => props.status === 'ready' && props.items.length === 0 && searching.value,
|
||||
)
|
||||
const showGrid = computed(() => props.status === 'ready' && props.items.length > 0)
|
||||
const showLoadMore = computed(() => showGrid.value && props.hasMore && !props.selected)
|
||||
const noResultsTitle = computed(() => `No images match "${props.query}"`)
|
||||
|
||||
function clearDebounce() {
|
||||
if (debounceTimer !== null) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(searchText, (value) => {
|
||||
clearDebounce()
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null
|
||||
emit('search', value.trim())
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
function clearSearch() {
|
||||
clearDebounce()
|
||||
searchText.value = ''
|
||||
emit('search', '')
|
||||
}
|
||||
|
||||
function isSelected(item: MediaItem) {
|
||||
return props.selected?.id === item.id
|
||||
}
|
||||
|
||||
function isInArticle(item: MediaItem) {
|
||||
return props.inArticleIds.includes(item.id)
|
||||
}
|
||||
|
||||
function onTileKeydown(event: KeyboardEvent, item: MediaItem) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (!event.repeat) {
|
||||
emit('select', item)
|
||||
}
|
||||
}
|
||||
|
||||
function onTileKeyup(event: KeyboardEvent) {
|
||||
if (event.key === ' ') {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.firstNewId,
|
||||
async (id) => {
|
||||
if (id === null) {
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
grid.value?.querySelector<HTMLElement>(`[data-image-id="${id}"]`)?.focus()
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(clearDebounce)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col gap-3">
|
||||
<UInput
|
||||
v-model="searchText"
|
||||
type="text"
|
||||
icon="i-lucide-search"
|
||||
placeholder="Search by filename"
|
||||
aria-label="Search by filename"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm', leadingIcon: SEARCH_ICON_SIZE }"
|
||||
>
|
||||
<template v-if="searchText" #trailing>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="link"
|
||||
size="xs"
|
||||
icon="i-lucide-x"
|
||||
aria-label="Clear search"
|
||||
class="text-muted"
|
||||
@click="clearSearch"
|
||||
/>
|
||||
</template>
|
||||
</UInput>
|
||||
|
||||
<UAlert
|
||||
v-if="status === 'error'"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't load your library"
|
||||
description="Check your connection and try again."
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
Try again
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<UEmpty
|
||||
v-else-if="showEmptyLibrary"
|
||||
variant="naked"
|
||||
icon="i-lucide-images"
|
||||
title="Your library is empty"
|
||||
description="Images you upload appear here, ready to use in any article."
|
||||
:ui="EMPTY_STATE_UI"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton icon="i-lucide-upload" class="h-11 px-4 md:h-8" @click="emit('upload')">
|
||||
Upload an image
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
|
||||
<UEmpty
|
||||
v-else-if="showNoResults"
|
||||
variant="naked"
|
||||
icon="i-lucide-search"
|
||||
:title="noResultsTitle"
|
||||
description="Check the spelling, or search for a shorter part of the filename."
|
||||
:ui="EMPTY_STATE_UI"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
|
||||
@click="clearSearch"
|
||||
>
|
||||
Clear search
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
|
||||
<div
|
||||
v-else-if="status === 'loading' && showSkeleton"
|
||||
aria-busy="true"
|
||||
class="grid grid-cols-3 gap-x-2 gap-y-3 md:grid-cols-4 md:gap-x-3"
|
||||
>
|
||||
<div
|
||||
v-for="n in firstLoadSkeletons"
|
||||
:key="n"
|
||||
data-testid="picker-skeleton-tile"
|
||||
class="flex flex-col gap-1.5"
|
||||
>
|
||||
<USkeleton class="aspect-square w-full rounded-none bg-accented" />
|
||||
<USkeleton class="h-2.5 w-[70%] rounded-none bg-accented" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="showGrid">
|
||||
<span data-testid="picker-summary" class="text-xs/5 text-muted">{{ summary }}</span>
|
||||
<div class="-m-1 min-h-0 overflow-y-auto p-1" :class="scrollHeight">
|
||||
<div ref="grid" class="grid grid-cols-3 gap-x-2 gap-y-3 md:grid-cols-4 md:gap-x-3">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
data-picker-tile
|
||||
:data-image-id="item.id"
|
||||
:aria-label="accessibleName(item)"
|
||||
:aria-pressed="isSelected(item)"
|
||||
class="group flex min-w-0 flex-col gap-1.5 text-start focus-visible:outline-none"
|
||||
@click="emit('select', item)"
|
||||
@keydown="onTileKeydown($event, item)"
|
||||
@keyup="onTileKeyup"
|
||||
>
|
||||
<span
|
||||
class="relative block w-full group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-primary"
|
||||
:class="
|
||||
isSelected(item) ? 'outline-2 outline-offset-2 outline-primary' : ''
|
||||
"
|
||||
>
|
||||
<MediaThumb
|
||||
:item="item"
|
||||
size="md"
|
||||
class="aspect-square w-full border border-default"
|
||||
/>
|
||||
<span
|
||||
v-if="isInArticle(item)"
|
||||
class="absolute bottom-1 left-1 inline-flex h-4.5 items-center bg-secondary-200 px-1.25 text-[0.6875rem] font-medium text-primary-900 dark:bg-secondary-300"
|
||||
>In this article</span
|
||||
>
|
||||
<span
|
||||
v-if="isSelected(item)"
|
||||
data-testid="picker-check"
|
||||
class="absolute top-1 right-1 flex size-5 items-center justify-center bg-primary text-inverted"
|
||||
>
|
||||
<UIcon name="i-lucide-check" class="size-3.5" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="truncate text-xs/5 text-toned">{{ item.filename }}</span>
|
||||
</button>
|
||||
<template v-if="loadingMore && showSkeleton">
|
||||
<div
|
||||
v-for="n in columns"
|
||||
:key="`skeleton-${n}`"
|
||||
data-testid="picker-skeleton-tile"
|
||||
class="flex flex-col gap-1.5"
|
||||
>
|
||||
<USkeleton class="aspect-square w-full rounded-none bg-accented" />
|
||||
<USkeleton class="h-2.5 w-[70%] rounded-none bg-accented" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showLoadMore" class="flex justify-center">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:icon="loadingMore ? 'i-lucide-loader-circle' : undefined"
|
||||
:aria-disabled="loadingMore || undefined"
|
||||
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 aria-disabled:opacity-40 md:h-8"
|
||||
:ui="{ leadingIcon: loadingMore ? 'size-4 animate-spin' : 'size-4' }"
|
||||
@click="emit('load-more')"
|
||||
>
|
||||
{{ loadingMore ? 'Loading…' : 'Load more' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="selected"
|
||||
data-testid="picker-panel"
|
||||
class="flex flex-col gap-3 border-t border-default pt-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<MediaThumb :item="selected" size="sm" class="size-14 border border-default" />
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="truncate font-medium text-highlighted">{{
|
||||
selected.filename
|
||||
}}</span>
|
||||
<span class="text-xs/5 text-muted"
|
||||
>{{ formatDimensions(selected.width, selected.height) }} ·
|
||||
{{ formatSize(selected.size) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<UFormField
|
||||
label="Alt text (optional)"
|
||||
help="Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration."
|
||||
:ui="{
|
||||
label: 'font-medium text-highlighted',
|
||||
help: 'text-xs/5 text-muted',
|
||||
}"
|
||||
>
|
||||
<UInput
|
||||
v-model="alt"
|
||||
type="text"
|
||||
placeholder="e.g. North wall of the kitchen before plastering"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,12 +2,10 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import type { MediaCounts } from '@/composables/useMedia'
|
||||
import type { MediaControls, MediaFilter, MediaSort, MediaView } from '@/lib/mediaQuery'
|
||||
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 120
|
||||
|
||||
const TAB_TRIGGER =
|
||||
'h-9 px-0 py-0 data-[state=inactive]:text-toned in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:bottom-0 in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:h-0.5 max-md:h-11 max-md:text-[0.9375rem]'
|
||||
|
||||
const SORT_LABELS: Record<MediaSort, string> = {
|
||||
newest: 'Newest',
|
||||
oldest: 'Oldest',
|
||||
@@ -107,7 +105,7 @@ const sortItems = computed(() =>
|
||||
class="order-2 md:order-none md:-mb-px md:flex-1"
|
||||
:ui="{
|
||||
list: 'gap-5 border-b-0 p-0 max-md:border-b',
|
||||
trigger: TAB_TRIGGER,
|
||||
trigger: UNDERLINE_TAB_TRIGGER,
|
||||
}"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import ArticleDetails from '../ArticleDetails.vue'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function mountDetails(article: Omit<Article, 'images'>) {
|
||||
return mount(ArticleDetails, {
|
||||
props: { article: article as Article },
|
||||
global: { plugins: [uiPlugin] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
}
|
||||
|
||||
const baseArticle = {
|
||||
id: 3,
|
||||
title: 'Kitchen',
|
||||
body: '<p>a</p>',
|
||||
is_online: false,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: '2026-09-26T11:02:00Z',
|
||||
}
|
||||
|
||||
describe('An article from the API carries no images field', () => {
|
||||
it('renders the details with an images count read from the body', () => {
|
||||
const wrapper = mountDetails({
|
||||
...baseArticle,
|
||||
body: '<p>a</p><img src="/a.png"><img src="/b.png">',
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Images')
|
||||
expect(wrapper.text()).toContain('2')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('counts no image for a body without any', () => {
|
||||
const wrapper = mountDetails(baseArticle)
|
||||
|
||||
expect(wrapper.text()).toContain('Images')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -34,7 +34,6 @@ async function mountArticleEditor(body = '<p>Hello</p>') {
|
||||
id: 12,
|
||||
title: 'T',
|
||||
body,
|
||||
images: [],
|
||||
is_online: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
@@ -106,3 +105,17 @@ describe('removeImage removes only the node whose data-image-id matches', () =>
|
||||
expect(editor.emitted('update:modelValue')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('imageSrc reads the src of the node whose data-image-id matches', () => {
|
||||
it('returns the src of the matching image and null for an unknown id', async () => {
|
||||
const wrapper = await mountArticleEditor(
|
||||
'<p>Hello</p><img src="/static/uploadedImages/a.png" alt="" data-image-id="10">',
|
||||
)
|
||||
const vm = wrapper.findComponent(ArticleEditor).vm as unknown as {
|
||||
imageSrc: (id: number) => string | null
|
||||
}
|
||||
|
||||
expect(vm.imageSrc(10)).toBe('/static/uploadedImages/a.png')
|
||||
expect(vm.imageSrc(999)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -74,14 +74,9 @@ describe('Cancelling — the action is in flight and the dialog cannot be dismis
|
||||
await clickButton('Cancel registration')
|
||||
|
||||
expect(action).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
(findButtonElement('Keep my account') as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true)
|
||||
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(findButtonElement('Cancelling…')).toBeTruthy()
|
||||
expect(
|
||||
(findButtonElement('Cancelling…') as HTMLButtonElement).disabled,
|
||||
).toBe(true)
|
||||
expect((findButtonElement('Cancelling…') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(document.querySelector('[aria-busy="true"]')).toBeTruthy()
|
||||
|
||||
resolveAction?.()
|
||||
@@ -163,26 +158,60 @@ describe('dismissResolvesTo="confirm" makes Esc/overlay resolve as confirmed', (
|
||||
|
||||
describe('Cancel failed — the dialog stays open and shows the failure inline', () => {
|
||||
it('re-enables both buttons and shows the rejection message inline', async () => {
|
||||
const action = vi.fn(() =>
|
||||
Promise.reject(new Error("Couldn't cancel registration.")),
|
||||
)
|
||||
const action = vi.fn(() => Promise.reject(new Error("Couldn't cancel registration.")))
|
||||
const wrapper = mountDialog(action)
|
||||
await flushPromises()
|
||||
|
||||
await clickButton('Cancel registration')
|
||||
|
||||
expect(wrapper.emitted('close')).toBeFalsy()
|
||||
expect(document.body.textContent).toContain(
|
||||
"Couldn't cancel registration.",
|
||||
)
|
||||
expect(
|
||||
(findButtonElement('Keep my account') as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false)
|
||||
expect(
|
||||
(findButtonElement('Cancel registration') as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false)
|
||||
expect(document.body.textContent).toContain("Couldn't cancel registration.")
|
||||
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(false)
|
||||
expect((findButtonElement('Cancel registration') as HTMLButtonElement).disabled).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A neutral confirm with an image preview', () => {
|
||||
function mountPreview(preview: { src: string; filename?: string; size?: string }) {
|
||||
return mount(ConfirmDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
title: 'Remove this image from the article?',
|
||||
description: 'It stays in your library.',
|
||||
confirmLabel: 'Remove image',
|
||||
cancelLabel: 'Cancel',
|
||||
pendingLabel: 'Removing…',
|
||||
tone: 'neutral',
|
||||
preview,
|
||||
action: async () => {},
|
||||
},
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
}
|
||||
|
||||
it('shows the thumbnail, the filename and the size, and a primary confirm', async () => {
|
||||
const wrapper = mountPreview({
|
||||
src: '/media/north-wall.jpg',
|
||||
filename: 'north-wall.jpg',
|
||||
size: '3.9 MB',
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const row = document.body.querySelector('[data-testid="confirm-preview"]')!
|
||||
expect(row.textContent).toContain('north-wall.jpg')
|
||||
expect(row.textContent).toContain('Stays in the library · 3.9 MB')
|
||||
expect(row.querySelector('img')?.getAttribute('src')).toBe('/media/north-wall.jpg')
|
||||
expect(findButtonElement('Remove image')?.className).not.toContain('bg-error')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows the thumbnail and "Stays in the library" for an image the session has not seen', async () => {
|
||||
const wrapper = mountPreview({ src: '/media/seven.jpg' })
|
||||
await flushPromises()
|
||||
|
||||
const row = document.body.querySelector('[data-testid="confirm-preview"]')!
|
||||
expect(row.textContent?.replace(/\s+/g, ' ').trim()).toBe('Stays in the library')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,44 @@
|
||||
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 { nextTick } from 'vue'
|
||||
import * as fileHash from '@/lib/fileHash'
|
||||
import * as imageUpload from '@/lib/imageUpload'
|
||||
import { ApiError, NetworkError, redirectToLogin, redirectUnverified } from '@/composables/useApi'
|
||||
import { fixture } from '@/views/__tests__/mediaTestKit'
|
||||
import {
|
||||
altField,
|
||||
apiKit,
|
||||
click,
|
||||
dialogText,
|
||||
findButton,
|
||||
hashCalls,
|
||||
installApi,
|
||||
installEmptyLibrary,
|
||||
listCalls,
|
||||
modalOpen,
|
||||
mountUploader,
|
||||
openUploader,
|
||||
pageBody,
|
||||
selectedTab,
|
||||
settle,
|
||||
tabButtons,
|
||||
textOf,
|
||||
} from './pickerKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return {
|
||||
...actual,
|
||||
useApi: vi.fn(),
|
||||
redirectToLogin: vi.fn(),
|
||||
redirectUnverified: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/imageUpload', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/imageUpload')>()
|
||||
return {
|
||||
...actual,
|
||||
validateImageFile: vi.fn(actual.validateImageFile),
|
||||
}
|
||||
return { ...actual, validateImageFile: vi.fn(actual.validateImageFile) }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/imageCrop', async (importOriginal) => {
|
||||
@@ -22,6 +50,8 @@ vi.mock('@/lib/imageCrop', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const NORTH_WALL_HASH = 'a'.repeat(64)
|
||||
|
||||
class FakeXMLHttpRequest {
|
||||
static instances: FakeXMLHttpRequest[] = []
|
||||
|
||||
@@ -88,7 +118,7 @@ class FakeXMLHttpRequest {
|
||||
}
|
||||
|
||||
function latestXHR() {
|
||||
return FakeXMLHttpRequest.instances[FakeXMLHttpRequest.instances.length - 1]
|
||||
return FakeXMLHttpRequest.instances[FakeXMLHttpRequest.instances.length - 1]!
|
||||
}
|
||||
|
||||
function mib(value: number) {
|
||||
@@ -101,38 +131,6 @@ function fileFixture(name: string, type: string, sizeMB: number) {
|
||||
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, imageId: number) => emit('uploaded', location, imageId),
|
||||
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"]')!
|
||||
}
|
||||
@@ -149,21 +147,11 @@ async function chooseFile(file: File) {
|
||||
})
|
||||
input.dispatchEvent(new Event('change'))
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function findButton(text: string) {
|
||||
return Array.from(document.body.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === text,
|
||||
)
|
||||
await settle()
|
||||
}
|
||||
|
||||
async function clickButton(text: string) {
|
||||
findButton(text)?.click()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await click(findButton(text))
|
||||
}
|
||||
|
||||
async function chooseRasterFileAndConfirmUpload(file: File) {
|
||||
@@ -171,47 +159,78 @@ async function chooseRasterFileAndConfirmUpload(file: File) {
|
||||
await clickButton('Upload image')
|
||||
}
|
||||
|
||||
function mediaItem(overrides: Record<string, unknown> = {}) {
|
||||
return { ...fixture([1])[0]!, ...overrides }
|
||||
}
|
||||
|
||||
let wrapper: ReturnType<typeof mountUploader> | null = null
|
||||
|
||||
async function openOnUpload(routes: Parameters<typeof installEmptyLibrary>[0] = {}) {
|
||||
installEmptyLibrary(routes)
|
||||
wrapper = mountUploader()
|
||||
await openUploader(wrapper)
|
||||
}
|
||||
|
||||
const RealXMLHttpRequest = global.XMLHttpRequest
|
||||
|
||||
beforeEach(() => {
|
||||
FakeXMLHttpRequest.instances = []
|
||||
global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest
|
||||
vi.mocked(fileHash.hashFile).mockResolvedValue(NORTH_WALL_HASH)
|
||||
vi.mocked(redirectToLogin).mockClear()
|
||||
vi.mocked(redirectUnverified).mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
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)
|
||||
describe("The toolbar's image button opens on Upload when the library is empty", () => {
|
||||
it('shows both tabs, the drop zone copy and a footer with Cancel only', async () => {
|
||||
await openOnUpload()
|
||||
|
||||
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(dialogText()).toContain('Add an image')
|
||||
expect(tabButtons().map(textOf)).toEqual(['Upload', 'Library'])
|
||||
expect(textOf(selectedTab())).toBe('Upload')
|
||||
expect(dialogText()).toContain('Drop an image here, or choose a file')
|
||||
expect(dialogText()).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
|
||||
expect(findButton('Choose file')).toBeUndefined()
|
||||
expect(findButton('Cancel')).toBeTruthy()
|
||||
expect(findButton('Insert image')).toBeUndefined()
|
||||
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)
|
||||
await openOnUpload()
|
||||
|
||||
dropZone().dispatchEvent(new Event('dragover', { bubbles: true, cancelable: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.textContent).toContain('Drop to upload')
|
||||
wrapper.unmount()
|
||||
expect(dialogText()).toContain('Drop to upload')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Choosing a file the browser can reject never sends a request', () => {
|
||||
describe('Dropping a file opens Upload with the file', () => {
|
||||
it('looks the hash up, sends no list request and opens the crop step', async () => {
|
||||
installApi()
|
||||
wrapper = mountUploader({ initialFile: fileFixture('before.png', 'image/png', 3.9) })
|
||||
await openUploader(wrapper)
|
||||
|
||||
expect(hashCalls()).toEqual([NORTH_WALL_HASH])
|
||||
expect(listCalls()).toHaveLength(0)
|
||||
expect(dialogText()).toContain('Crop and resize')
|
||||
expect(dialogText()).toContain('before.png')
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Choosing a file the browser can reject sends nothing', () => {
|
||||
it.each([
|
||||
{
|
||||
filename: 'plan.pdf',
|
||||
@@ -234,183 +253,368 @@ describe('Choosing a file the browser can reject never sends a request', () => {
|
||||
])(
|
||||
'rejects $filename with the exact copy',
|
||||
async ({ filename, contentType, sizeMB, description }) => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await openOnUpload()
|
||||
|
||||
await chooseFile(fileFixture(filename, contentType, sizeMB))
|
||||
|
||||
expect(hashCalls()).toHaveLength(0)
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
|
||||
expect(document.body.textContent).toContain(`"${filename}" can't be used`)
|
||||
expect(document.body.textContent).toContain(description)
|
||||
expect(dialogText()).toContain(`"${filename}" can't be used`)
|
||||
expect(dialogText()).toContain(description)
|
||||
expect(findButton('Choose another file')).toBeTruthy()
|
||||
expect(findButton('Cancel')).toBeTruthy()
|
||||
wrapper.unmount()
|
||||
expect(tabButtons()).toHaveLength(0)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('"Choose another file" from the rejected state returns to idle', () => {
|
||||
it('shows the idle state again', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
it('shows the idle state again, with the tabs', async () => {
|
||||
await openOnUpload()
|
||||
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()
|
||||
expect(dialogText()).toContain('Drop an image here, or choose a file')
|
||||
expect(tabButtons().map(textOf)).toEqual(['Upload', 'Library'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('A file whose content exists shows the duplicate card and sends nothing', () => {
|
||||
it('shows the card with the oldest match and its two actions', async () => {
|
||||
await openOnUpload({ hash: () => pageBody([mediaItem()], null) })
|
||||
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
|
||||
const card = textOf(document.body.querySelector('[data-testid="duplicate-card"]'))
|
||||
expect(card).toContain('Already in your library')
|
||||
expect(card).toContain(
|
||||
'north-wall.jpg matches an image uploaded on 12 Sep 2026 by Marta Alves.',
|
||||
)
|
||||
expect(findButton('Use existing')).toBeTruthy()
|
||||
expect(findButton('Upload anyway')).toBeTruthy()
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
|
||||
expect(dialogText()).not.toContain('Crop and resize')
|
||||
expect(tabButtons()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('names the oldest of several matches', async () => {
|
||||
await openOnUpload({
|
||||
hash: () =>
|
||||
pageBody(
|
||||
[
|
||||
mediaItem({
|
||||
id: 9,
|
||||
created_at: '2026-09-20T12:00:00Z',
|
||||
uploaded_by: 'jonas',
|
||||
}),
|
||||
mediaItem({
|
||||
id: 3,
|
||||
created_at: '2026-09-01T12:00:00Z',
|
||||
uploaded_by: 'Inès Duarte',
|
||||
}),
|
||||
],
|
||||
null,
|
||||
),
|
||||
})
|
||||
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
|
||||
expect(dialogText()).toContain(
|
||||
'north-wall.jpg matches an image uploaded on 1 Sep 2026 by Inès Duarte.',
|
||||
)
|
||||
})
|
||||
|
||||
it('drops the "by" part when the uploader is unknown', async () => {
|
||||
await openOnUpload({ hash: () => pageBody([mediaItem({ uploaded_by: '' })], null) })
|
||||
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
|
||||
expect(dialogText()).toContain('north-wall.jpg matches an image uploaded on 12 Sep 2026.')
|
||||
})
|
||||
|
||||
it('goes to the Library with the existing image selected when "Use existing" is activated', async () => {
|
||||
await openOnUpload({ hash: () => pageBody([mediaItem()], null) })
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
|
||||
await clickButton('Use existing')
|
||||
|
||||
expect(textOf(selectedTab())).toBe('Library')
|
||||
expect(textOf(document.body.querySelector('[data-testid="picker-panel"]'))).toContain(
|
||||
'north-wall.jpg',
|
||||
)
|
||||
expect(altField()!.value).toBe('')
|
||||
expect(findButton('Insert image')?.disabled).toBe(false)
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('continues to crop and upload without a second lookup when "Upload anyway" is activated', async () => {
|
||||
await openOnUpload({ hash: () => pageBody([mediaItem()], null) })
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
|
||||
await clickButton('Upload anyway')
|
||||
|
||||
expect(dialogText()).toContain('Crop and resize')
|
||||
expect(hashCalls()).toHaveLength(1)
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uploads an SVG at once when "Upload anyway" is activated', async () => {
|
||||
await openOnUpload({ hash: () => pageBody([mediaItem()], null) })
|
||||
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
|
||||
|
||||
await clickButton('Upload anyway')
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
|
||||
expect(latestXHR().url).toBe('/v1/media/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The duplicate check is skipped when it cannot run', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'crypto.subtle is undefined',
|
||||
arrange: () => vi.mocked(fileHash.hashFile).mockResolvedValue(null),
|
||||
routes: {},
|
||||
},
|
||||
{
|
||||
name: 'the lookup resolves with a 500 response',
|
||||
arrange: () => undefined,
|
||||
routes: { hash: () => Promise.reject(new ApiError(500)) },
|
||||
},
|
||||
{
|
||||
name: 'the lookup never reaches the server',
|
||||
arrange: () => undefined,
|
||||
routes: { hash: () => Promise.reject(new NetworkError()) },
|
||||
},
|
||||
])('continues to the crop step with no card when $name', async ({ arrange, routes }) => {
|
||||
arrange()
|
||||
await openOnUpload(routes)
|
||||
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
|
||||
expect(dialogText()).toContain('Crop and resize')
|
||||
expect(document.body.querySelector('[data-testid="duplicate-card"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('uploads an SVG when the lookup fails', async () => {
|
||||
await openOnUpload({ hash: () => Promise.reject(new NetworkError()) })
|
||||
|
||||
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('A valid upload shows progress and its cancel action reads "Cancel upload"', () => {
|
||||
it('sends the resized file and reports live progress', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
it('sends the resized file to /v1/media/ and reports live progress', async () => {
|
||||
await openOnUpload()
|
||||
|
||||
await chooseRasterFileAndConfirmUpload(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.method).toBe('POST')
|
||||
expect(xhr.url).toBe('/v1/media/')
|
||||
expect(xhr.withCredentials).toBe(true)
|
||||
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 420 KB')
|
||||
expect(dialogText()).toContain('north-wall.jpg 0%')
|
||||
expect(dialogText()).toContain('Uploading 0.0 of 420 KB')
|
||||
expect(findButton('Cancel upload')).toBeTruthy()
|
||||
expect(tabButtons()).toHaveLength(0)
|
||||
|
||||
xhr.fireProgress(180 * 1024, 420 * 1024)
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.textContent).toContain('north-wall.jpg 43%')
|
||||
expect(document.body.textContent).toContain('Uploading 180 of 420 KB')
|
||||
wrapper.unmount()
|
||||
expect(dialogText()).toContain('north-wall.jpg 43%')
|
||||
expect(dialogText()).toContain('Uploading 180 of 420 KB')
|
||||
})
|
||||
|
||||
it('reads megabytes for a larger file', async () => {
|
||||
await openOnUpload()
|
||||
await chooseFile(fileFixture('logo.svg', 'image/svg+xml', 3.9))
|
||||
|
||||
latestXHR().fireProgress(mib(2.4), mib(3.9))
|
||||
await nextTick()
|
||||
|
||||
expect(dialogText()).toContain('logo.svg 62%')
|
||||
expect(dialogText()).toContain('Uploading 2.4 of 3.9 MB')
|
||||
})
|
||||
})
|
||||
|
||||
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 openOnUpload()
|
||||
await chooseRasterFileAndConfirmUpload(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()
|
||||
expect(modalOpen()).toBe(false)
|
||||
expect(wrapper!.emitted('uploaded')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A successful upload emits the location and auto-closes 600 ms later', () => {
|
||||
describe('A successful upload emits the library item and auto-closes 600 ms later', () => {
|
||||
it('shows "Image added" then closes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
const xhr = latestXHR()
|
||||
installEmptyLibrary()
|
||||
wrapper = mountUploader()
|
||||
;(wrapper.vm as unknown as { open: boolean }).open = true
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
|
||||
const item = mediaItem({ id: 13, url: '/static/uploadedImages/13.jpg' })
|
||||
|
||||
xhr.resolve(
|
||||
201,
|
||||
JSON.stringify({ id: 7, location: '/static/uploadedImages/north-wall.jpg' }),
|
||||
)
|
||||
latestXHR().resolve(201, JSON.stringify(item))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(wrapper.emitted('uploaded')).toEqual([['/static/uploadedImages/north-wall.jpg', 7]])
|
||||
expect(document.body.textContent).toContain('Image added')
|
||||
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
|
||||
expect(wrapper.emitted('uploaded')).toEqual([[item]])
|
||||
expect(dialogText()).toContain('Image added')
|
||||
expect(modalOpen()).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(590)
|
||||
|
||||
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
|
||||
expect(modalOpen()).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
vi.useRealTimers()
|
||||
expect(modalOpen()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('A server-side upload failure keeps the article unchanged and offers a retry', () => {
|
||||
describe('A server answer for a file that passed the browser checks', () => {
|
||||
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 chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
const xhr = latestXHR()
|
||||
{
|
||||
file: 'notes.pdf',
|
||||
type: 'image/png',
|
||||
sizeMB: 0.2,
|
||||
status: 415,
|
||||
title: '"notes.pdf" can\'t be used',
|
||||
text: 'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
|
||||
confirm: 'Choose another file',
|
||||
},
|
||||
{
|
||||
file: 'north-wall.tif',
|
||||
type: 'image/png',
|
||||
sizeMB: 14.2,
|
||||
status: 413,
|
||||
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',
|
||||
},
|
||||
{
|
||||
file: 'broken.png',
|
||||
type: 'image/png',
|
||||
sizeMB: 3,
|
||||
status: 400,
|
||||
title: '"broken.png" can\'t be used',
|
||||
text: 'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
|
||||
confirm: 'Choose another file',
|
||||
},
|
||||
{
|
||||
file: 'north-wall.jpg',
|
||||
type: 'image/jpeg',
|
||||
sizeMB: 3.9,
|
||||
status: 500,
|
||||
title: 'Upload failed',
|
||||
text: "The server didn't accept the file. Your article hasn't changed.",
|
||||
confirm: 'Try again',
|
||||
},
|
||||
{
|
||||
file: 'north-wall.jpg',
|
||||
type: 'image/jpeg',
|
||||
sizeMB: 3.9,
|
||||
status: 429,
|
||||
title: 'Upload failed',
|
||||
text: "The server didn't accept the file. Your article hasn't changed.",
|
||||
confirm: 'Try again',
|
||||
},
|
||||
{
|
||||
file: 'north-wall.jpg',
|
||||
type: 'image/jpeg',
|
||||
sizeMB: 3.9,
|
||||
status: 0,
|
||||
title: 'Upload failed',
|
||||
text: "The server didn't accept the file. Your article hasn't changed.",
|
||||
confirm: 'Try again',
|
||||
},
|
||||
])(
|
||||
'answers $status with $title',
|
||||
async ({ file, type, sizeMB, status, title, text, confirm }) => {
|
||||
await openOnUpload()
|
||||
const chosen = fileFixture(file, type, sizeMB)
|
||||
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
|
||||
await chooseFile(chosen)
|
||||
if (findButton('Upload image')) {
|
||||
await clickButton('Upload image')
|
||||
}
|
||||
const xhr = latestXHR()
|
||||
|
||||
trigger(xhr)
|
||||
await flushPromises()
|
||||
if (status === 0) {
|
||||
xhr.fail()
|
||||
} else {
|
||||
xhr.resolve(status, '')
|
||||
}
|
||||
await settle()
|
||||
|
||||
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()
|
||||
})
|
||||
expect(dialogText()).toContain(title)
|
||||
expect(dialogText()).toContain(text)
|
||||
expect(findButton(confirm)).toBeTruthy()
|
||||
expect(wrapper!.emitted('uploaded')).toBeUndefined()
|
||||
expect(tabButtons()).toHaveLength(0)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
it('resends the same file under field "file" without a new lookup', async () => {
|
||||
await openOnUpload()
|
||||
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
latestXHR().resolve(500, '')
|
||||
await flushPromises()
|
||||
await settle()
|
||||
|
||||
await clickButton('Try again')
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(2)
|
||||
const xhr = latestXHR()
|
||||
expect(xhr.url).toBe('/v1/article/12/image')
|
||||
expect(xhr.url).toBe('/v1/media/')
|
||||
expect((xhr.sentBody?.get('file') as File).name).toBe('north-wall.jpg')
|
||||
expect(document.body.textContent).toContain('north-wall.jpg 0%')
|
||||
wrapper.unmount()
|
||||
expect(hashCalls()).toHaveLength(1)
|
||||
expect(dialogText()).toContain('north-wall.jpg 0%')
|
||||
})
|
||||
})
|
||||
|
||||
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 chooseRasterFileAndConfirmUpload(fileFixture('notes.pdf', 'image/png', 1.2))
|
||||
const xhr = latestXHR()
|
||||
describe('Session errors are global', () => {
|
||||
it('hands a 401 on the upload to the login redirect with no local copy', async () => {
|
||||
await openOnUpload()
|
||||
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
|
||||
|
||||
xhr.resolve(400, 'the file must be a PNG, JPEG, WEBP, or SVG image')
|
||||
await flushPromises()
|
||||
latestXHR().resolve(401, '')
|
||||
await settle()
|
||||
|
||||
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()
|
||||
expect(redirectToLogin).toHaveBeenCalledTimes(1)
|
||||
expect(dialogText()).not.toContain('Upload failed')
|
||||
})
|
||||
})
|
||||
|
||||
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 chooseRasterFileAndConfirmUpload(fileFixture('north-wall.tif', 'image/png', 14.2))
|
||||
const xhr = latestXHR()
|
||||
it('hands a 403 on the upload to the unverified redirect with no local copy', async () => {
|
||||
await openOnUpload()
|
||||
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
|
||||
|
||||
xhr.resolve(400, 'the file exceeds the maximum upload size')
|
||||
await flushPromises()
|
||||
latestXHR().resolve(403, '')
|
||||
await settle()
|
||||
|
||||
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()
|
||||
expect(redirectUnverified).toHaveBeenCalledTimes(1)
|
||||
expect(dialogText()).not.toContain('Upload failed')
|
||||
})
|
||||
|
||||
it('stops on a 401 from the hash lookup with no local copy', async () => {
|
||||
await openOnUpload({ hash: () => Promise.reject(new ApiError(401)) })
|
||||
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
|
||||
expect(dialogText()).not.toContain('Crop and resize')
|
||||
expect(apiKit.request).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,18 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import { nextTick } from 'vue'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import ImageUploader from '../ImageUploader.vue'
|
||||
import { installEmptyLibrary, mountUploader, openUploader } from './pickerKit'
|
||||
import { renderCroppedImage } from '@/lib/imageCrop'
|
||||
import type { CropRect } from '@/lib/imageCrop'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn(async () => null) }))
|
||||
|
||||
vi.mock('@/lib/imageCrop', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
|
||||
return {
|
||||
@@ -95,36 +102,7 @@ function fileFixture(name: string, type: string, sizeMB: number) {
|
||||
return file
|
||||
}
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { ImageUploader },
|
||||
props: {
|
||||
articleId: { type: Number, required: true },
|
||||
},
|
||||
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" @uploaded="onUploaded" @close="onClose" />`,
|
||||
})
|
||||
|
||||
function mountUploader() {
|
||||
return mount(Host, {
|
||||
props: { articleId: 12 },
|
||||
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()
|
||||
}
|
||||
const openModal = openUploader
|
||||
|
||||
function fileInput() {
|
||||
return document.body.querySelector<HTMLInputElement>('[data-testid="image-file-input"]')!
|
||||
@@ -138,8 +116,7 @@ async function chooseFile(file: File) {
|
||||
})
|
||||
input.dispatchEvent(new Event('change'))
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function findButton(text: string) {
|
||||
@@ -151,8 +128,7 @@ function findButton(text: string) {
|
||||
async function clickButton(text: string) {
|
||||
findButton(text)?.click()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function radioPill(groupLabel: string, text: string) {
|
||||
@@ -187,6 +163,7 @@ function north_wall() {
|
||||
const RealXMLHttpRequest = global.XMLHttpRequest
|
||||
|
||||
beforeEach(() => {
|
||||
installEmptyLibrary()
|
||||
FakeXMLHttpRequest.instances = []
|
||||
global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest
|
||||
})
|
||||
@@ -223,7 +200,7 @@ describe('An SVG skips the crop step and uploads immediately', () => {
|
||||
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
|
||||
expect(latestXHR().url).toBe('/v1/article/12/image')
|
||||
expect(latestXHR().url).toBe('/v1/media/')
|
||||
expect(document.body.textContent).not.toContain('Crop and resize')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { ApiError, NetworkError } from '@/composables/useApi'
|
||||
import { MOBILE_WIDTH } from './viewport'
|
||||
import { fixture } from '@/views/__tests__/mediaTestKit'
|
||||
import {
|
||||
altField,
|
||||
apiKit,
|
||||
click,
|
||||
clickTab,
|
||||
dialogText,
|
||||
findButton,
|
||||
installApi,
|
||||
listCalls,
|
||||
modalOpen,
|
||||
mountUploader,
|
||||
openUploader,
|
||||
pageBody,
|
||||
press,
|
||||
searchField,
|
||||
secondPage,
|
||||
selectedTab,
|
||||
selectTile,
|
||||
settle,
|
||||
skeletonTiles,
|
||||
textOf,
|
||||
tile,
|
||||
tiles,
|
||||
typeInto,
|
||||
type PickerRoutes,
|
||||
} from './pickerKit'
|
||||
|
||||
vi.mock('@/lib/imageCrop', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
|
||||
return {
|
||||
...actual,
|
||||
readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
let wrapper: ReturnType<typeof mountUploader> | null = null
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function openLibrary(routes: PickerRoutes = {}) {
|
||||
installApi(routes)
|
||||
wrapper = mountUploader()
|
||||
await openUploader(wrapper)
|
||||
}
|
||||
|
||||
async function openWithFakeTimers(routes: PickerRoutes = {}, width?: number) {
|
||||
vi.useFakeTimers()
|
||||
installApi(routes)
|
||||
wrapper = mountUploader({}, width)
|
||||
;(wrapper.vm as unknown as { open: boolean }).open = true
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
|
||||
function summary() {
|
||||
return textOf(document.body.querySelector('[data-testid="picker-summary"]'))
|
||||
}
|
||||
|
||||
async function searchFor(value: string) {
|
||||
await typeInto(searchField(), value)
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
await settle()
|
||||
}
|
||||
|
||||
describe('The library has images, so Library opens', () => {
|
||||
it('selects Library with a disabled "Insert image" and draws skeletons only after 150 ms', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await openWithFakeTimers({ list: () => pending.promise })
|
||||
|
||||
expect(textOf(selectedTab())).toBe('Library')
|
||||
expect(findButton('Cancel')?.disabled).toBe(false)
|
||||
expect(findButton('Insert image')?.disabled).toBe(true)
|
||||
expect(skeletonTiles()).toHaveLength(0)
|
||||
expect(tiles()).toHaveLength(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(skeletonTiles()).toHaveLength(8)
|
||||
|
||||
pending.resolve(pageBody())
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(skeletonTiles()).toHaveLength(0)
|
||||
expect(tiles()).toHaveLength(12)
|
||||
})
|
||||
|
||||
it('draws 9 skeleton tiles on mobile', async () => {
|
||||
await openWithFakeTimers({ list: () => new Promise(() => undefined) }, MOBILE_WIDTH)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(skeletonTiles()).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('asks for 24 images and nothing else', async () => {
|
||||
await openLibrary()
|
||||
|
||||
expect(listCalls()).toHaveLength(1)
|
||||
expect(listCalls()[0]!.toString()).toBe('limit=24')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The library is empty, so Upload opens', () => {
|
||||
it('opens the Upload tab idle, and Library then shows the empty panel', async () => {
|
||||
await openLibrary({ list: () => pageBody([], null) })
|
||||
|
||||
expect(textOf(selectedTab())).toBe('Upload')
|
||||
expect(dialogText()).toContain('Drop an image here, or choose a file')
|
||||
|
||||
await clickTab('Library')
|
||||
|
||||
expect(dialogText()).toContain('Your library is empty')
|
||||
expect(dialogText()).toContain(
|
||||
'Images you upload appear here, ready to use in any article.',
|
||||
)
|
||||
expect(findButton('Insert image')?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('switches to Upload from "Upload an image"', async () => {
|
||||
await openLibrary({ list: () => pageBody([], null) })
|
||||
await clickTab('Library')
|
||||
|
||||
await click(findButton('Upload an image'))
|
||||
|
||||
expect(textOf(selectedTab())).toBe('Upload')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A dropped file opens Upload and sends no list request', () => {
|
||||
it('stays quiet until the Library tab is selected', async () => {
|
||||
installApi({ hash: () => new Promise(() => undefined) })
|
||||
wrapper = mountUploader({
|
||||
initialFile: new File(['x'], 'before.png', { type: 'image/png' }),
|
||||
})
|
||||
await openUploader(wrapper)
|
||||
|
||||
expect(textOf(selectedTab())).toBe('Upload')
|
||||
expect(listCalls()).toHaveLength(0)
|
||||
|
||||
await clickTab('Library')
|
||||
|
||||
expect(listCalls()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Library, nothing selected', () => {
|
||||
it('shows the line, the 12 tiles in order and the tags on the images in the article', async () => {
|
||||
await openLibrary()
|
||||
|
||||
expect(summary()).toBe('Newest first · 48 images')
|
||||
expect(tiles().map((element) => element.getAttribute('data-image-id'))).toEqual(
|
||||
fixture().map((item) => String(item.id)),
|
||||
)
|
||||
const tagged = tiles()
|
||||
.filter((element) => textOf(element).includes('In this article'))
|
||||
.map((element) => element.getAttribute('data-image-id'))
|
||||
expect(tagged).toEqual(['1', '4'])
|
||||
expect(textOf(tile('north-wall.jpg'))).toContain('north-wall.jpg')
|
||||
expect(findButton('Load more')?.getAttribute('aria-disabled')).toBeNull()
|
||||
expect(findButton('Insert image')?.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('A search is active, so the count is not shown', () => {
|
||||
it('reads "Newest first"', async () => {
|
||||
await openLibrary({
|
||||
list: (query) => (query.get('q') ? pageBody(fixture([1, 12]), null) : pageBody()),
|
||||
})
|
||||
|
||||
await searchFor('wall')
|
||||
|
||||
expect(summary()).toBe('Newest first')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load more is hidden on the last page', () => {
|
||||
it('does not show "Load more" without a cursor', async () => {
|
||||
await openLibrary({ list: () => pageBody(fixture(), null) })
|
||||
|
||||
expect(findButton('Load more')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("A tile's accessible name", () => {
|
||||
it.each([
|
||||
['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 articles'],
|
||||
['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 article'],
|
||||
['window-frame.jpg', 'window-frame.jpg, 1.8 MB, unused'],
|
||||
])('names %s', async (filename, name) => {
|
||||
await openLibrary()
|
||||
|
||||
expect(tile(filename).getAttribute('aria-label')).toBe(name)
|
||||
})
|
||||
})
|
||||
|
||||
describe('A tile is selected by pointer or keyboard', () => {
|
||||
it.each([
|
||||
['clicks', async () => click(tile('kitchen-after.webp'))],
|
||||
[
|
||||
'presses Enter',
|
||||
async () => {
|
||||
press(tile('kitchen-after.webp'), 'Enter')
|
||||
await settle()
|
||||
},
|
||||
],
|
||||
[
|
||||
'presses Space',
|
||||
async () => {
|
||||
press(tile('kitchen-after.webp'), ' ')
|
||||
await settle()
|
||||
},
|
||||
],
|
||||
])('selects the tile when the user %s on it', async (_name, act) => {
|
||||
await openLibrary()
|
||||
|
||||
await act()
|
||||
|
||||
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('true')
|
||||
expect(
|
||||
tile('kitchen-after.webp').querySelector('[data-testid="picker-check"]'),
|
||||
).not.toBeNull()
|
||||
expect(findButton('Insert image')?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The selection panel', () => {
|
||||
it('shows the thumbnail, the details and the alt text field', async () => {
|
||||
await openLibrary()
|
||||
|
||||
await selectTile('kitchen-after.webp')
|
||||
|
||||
const panel = textOf(document.body.querySelector('[data-testid="picker-panel"]'))
|
||||
expect(panel).toContain('kitchen-after.webp')
|
||||
expect(panel).toContain('2400 × 1600 · 1.2 MB')
|
||||
expect(panel).toContain('Alt text (optional)')
|
||||
expect(panel).toContain(
|
||||
"Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration.",
|
||||
)
|
||||
expect(altField()?.value).toBe('')
|
||||
expect(findButton('Load more')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads "Vector · 8 KB" for an SVG', async () => {
|
||||
await openLibrary()
|
||||
|
||||
await selectTile('logo-renew.svg')
|
||||
|
||||
expect(textOf(document.body.querySelector('[data-testid="picker-panel"]'))).toContain(
|
||||
'Vector · 8 KB',
|
||||
)
|
||||
})
|
||||
|
||||
it('allows an image already in the article to be selected', async () => {
|
||||
await openLibrary()
|
||||
|
||||
await selectTile('north-wall.jpg')
|
||||
|
||||
expect(findButton('Insert image')?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection is single', () => {
|
||||
it('selects only the last tile and empties the alt text', async () => {
|
||||
await openLibrary()
|
||||
await selectTile('kitchen-after.webp')
|
||||
await typeInto(altField()!, 'A kitchen')
|
||||
|
||||
await selectTile('window-frame.jpg')
|
||||
|
||||
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('false')
|
||||
expect(tile('window-frame.jpg').getAttribute('aria-pressed')).toBe('true')
|
||||
expect(altField()!.value).toBe('')
|
||||
})
|
||||
|
||||
it('keeps the tile selected, and its alt text, when it is activated again', async () => {
|
||||
await openLibrary()
|
||||
await selectTile('kitchen-after.webp')
|
||||
await typeInto(altField()!, 'A kitchen')
|
||||
|
||||
await selectTile('kitchen-after.webp')
|
||||
|
||||
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('true')
|
||||
expect(altField()!.value).toBe('A kitchen')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Insert', () => {
|
||||
function kitchenAfterAtStaticUrl() {
|
||||
return pageBody(
|
||||
fixture().map((item) =>
|
||||
item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : item,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it('emits the item and the alt text, then closes at once with no write request', async () => {
|
||||
await openLibrary({ list: kitchenAfterAtStaticUrl })
|
||||
await selectTile('kitchen-after.webp')
|
||||
await typeInto(altField()!, 'A kitchen with new oak worktops and white tiles')
|
||||
|
||||
await click(findButton('Insert image'))
|
||||
|
||||
const [item, alt] = wrapper!.emitted('inserted')![0] as [
|
||||
{ id: number; url: string },
|
||||
string,
|
||||
]
|
||||
expect(item.id).toBe(2)
|
||||
expect(item.url).toBe('/static/uploadedImages/2.webp')
|
||||
expect(alt).toBe('A kitchen with new oak worktops and white tiles')
|
||||
expect(modalOpen()).toBe(false)
|
||||
expect(apiKit.request.mock.calls.every(([, options]) => !options?.method)).toBe(true)
|
||||
})
|
||||
|
||||
it('inserts an empty alt for a decorative image', async () => {
|
||||
await openLibrary()
|
||||
await selectTile('kitchen-after.webp')
|
||||
|
||||
await click(findButton('Insert image'))
|
||||
|
||||
expect(wrapper!.emitted('inserted')![0]![1]).toBe('')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['activates "Cancel"', async () => click(findButton('Cancel'))],
|
||||
[
|
||||
'activates the x',
|
||||
async () =>
|
||||
click(document.body.querySelector('[role="dialog"] button[aria-label="Close"]')),
|
||||
],
|
||||
[
|
||||
'presses Escape',
|
||||
async () => {
|
||||
document.body
|
||||
.querySelector('[role="dialog"]')!
|
||||
.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await settle()
|
||||
},
|
||||
],
|
||||
])('changes nothing when the user %s', async (_name, act) => {
|
||||
await openLibrary()
|
||||
await selectTile('kitchen-after.webp')
|
||||
await typeInto(altField()!, 'A kitchen')
|
||||
|
||||
await act()
|
||||
|
||||
expect(modalOpen()).toBe(false)
|
||||
expect(wrapper!.emitted('inserted')).toBeUndefined()
|
||||
expect(wrapper!.emitted('uploaded')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search', () => {
|
||||
it('searches after a 120 ms debounce, with no cursor, and clears the selection', async () => {
|
||||
await openWithFakeTimers()
|
||||
tile('kitchen-after.webp').click()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(findButton('Insert image')?.disabled).toBe(false)
|
||||
|
||||
await typeInto(searchField(), 'wall')
|
||||
await vi.advanceTimersByTimeAsync(119)
|
||||
|
||||
expect(listCalls()).toHaveLength(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
expect(listCalls()).toHaveLength(2)
|
||||
expect(listCalls()[1]!.get('q')).toBe('wall')
|
||||
expect(listCalls()[1]!.get('limit')).toBe('24')
|
||||
expect(listCalls()[1]!.has('cursor')).toBe(false)
|
||||
expect(findButton('Insert image')?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('draws skeleton tiles while a search loads, after 150 ms', async () => {
|
||||
await openWithFakeTimers({
|
||||
list: (query) => (query.get('q') ? new Promise(() => undefined) : pageBody()),
|
||||
})
|
||||
|
||||
await typeInto(searchField(), 'wall')
|
||||
await vi.advanceTimersByTimeAsync(120)
|
||||
|
||||
expect(skeletonTiles()).toHaveLength(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(skeletonTiles()).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('clears the search from the x in the field', async () => {
|
||||
await openLibrary({
|
||||
list: (query) => (query.get('q') ? pageBody(fixture([1, 12]), null) : pageBody()),
|
||||
})
|
||||
await searchFor('wall')
|
||||
|
||||
await click(document.body.querySelector('button[aria-label="Clear search"]'))
|
||||
|
||||
expect(searchField().value).toBe('')
|
||||
const last = listCalls().at(-1)!
|
||||
expect(last.has('q')).toBe(false)
|
||||
expect(last.get('limit')).toBe('24')
|
||||
})
|
||||
|
||||
it('shows the no results panel and "Clear search" empties the field', async () => {
|
||||
await openLibrary({
|
||||
list: (query) => (query.get('q') ? pageBody([], null) : pageBody()),
|
||||
})
|
||||
await searchFor('wall')
|
||||
|
||||
expect(dialogText()).toContain('No images match "wall"')
|
||||
expect(dialogText()).toContain(
|
||||
'Check the spelling, or search for a shorter part of the filename.',
|
||||
)
|
||||
expect(searchField().value).toBe('wall')
|
||||
expect(findButton('Insert image')?.disabled).toBe(true)
|
||||
|
||||
await click(findButton('Clear search'))
|
||||
|
||||
expect(searchField().value).toBe('')
|
||||
expect(listCalls().at(-1)!.has('q')).toBe(false)
|
||||
expect(tiles()).toHaveLength(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load more', () => {
|
||||
it('shows progress, appends the new tiles and moves focus to the first one', async () => {
|
||||
const more = deferred<unknown>()
|
||||
await openWithFakeTimers({
|
||||
list: (query) => (query.get('cursor') ? more.promise : pageBody()),
|
||||
})
|
||||
const button = findButton('Load more')!
|
||||
button.focus()
|
||||
|
||||
button.click()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const last = listCalls().at(-1)!
|
||||
expect(last.get('cursor')).toBe('c24')
|
||||
expect(last.get('limit')).toBe('24')
|
||||
const loading = findButton('Loading…')!
|
||||
expect(loading.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(document.activeElement).toBe(loading)
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(skeletonTiles()).toHaveLength(4)
|
||||
|
||||
more.resolve(pageBody(secondPage(), null))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await flushPromises()
|
||||
|
||||
expect(tiles()).toHaveLength(24)
|
||||
expect(skeletonTiles()).toHaveLength(0)
|
||||
expect(document.activeElement).toBe(tiles()[12])
|
||||
})
|
||||
|
||||
it('draws a row of 3 skeleton tiles on mobile', async () => {
|
||||
await openWithFakeTimers(
|
||||
{ list: (query) => (query.get('cursor') ? new Promise(() => undefined) : pageBody()) },
|
||||
MOBILE_WIDTH,
|
||||
)
|
||||
|
||||
findButton('Load more')!.click()
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(skeletonTiles()).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('combines the cursor with the search', async () => {
|
||||
await openLibrary({
|
||||
list: (query) => pageBody(query.get('q') ? fixture([1, 2]) : fixture(), 'c24'),
|
||||
})
|
||||
await searchFor('wall')
|
||||
|
||||
await click(findButton('Load more'))
|
||||
|
||||
const last = listCalls().at(-1)!
|
||||
expect(last.get('q')).toBe('wall')
|
||||
expect(last.get('limit')).toBe('24')
|
||||
expect(last.get('cursor')).toBe('c24')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The library fails to load', () => {
|
||||
it.each([
|
||||
['a 500 response', () => Promise.reject(new ApiError(500))],
|
||||
['a request that never reaches the server', () => Promise.reject(new NetworkError())],
|
||||
])('shows the alert for %s and retries', async (_name, failure) => {
|
||||
let failing = true
|
||||
await openLibrary({ list: () => (failing ? failure() : pageBody()) })
|
||||
|
||||
expect(dialogText()).toContain("Couldn't load your library")
|
||||
expect(dialogText()).toContain('Check your connection and try again.')
|
||||
expect(findButton('Insert image')?.disabled).toBe(true)
|
||||
|
||||
failing = false
|
||||
await click(findButton('Try again'))
|
||||
|
||||
expect(listCalls()).toHaveLength(2)
|
||||
expect(tiles()).toHaveLength(12)
|
||||
expect(dialogText()).not.toContain("Couldn't load your library")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
import { vi } from 'vitest'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import ImageUploader from '../ImageUploader.vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from './viewport'
|
||||
import { fixture, textOf } from '@/views/__tests__/mediaTestKit'
|
||||
|
||||
export { textOf }
|
||||
|
||||
export const FIRST_PAGE_CURSOR = 'c24'
|
||||
export const PICKER_COUNTS = { all: 48, unused: 2, duplicates: 2 }
|
||||
|
||||
export function pageBody(
|
||||
items: MediaItem[] = fixture(),
|
||||
next_cursor: string | null = FIRST_PAGE_CURSOR,
|
||||
) {
|
||||
return { items, next_cursor, counts: PICKER_COUNTS }
|
||||
}
|
||||
|
||||
export function secondPage() {
|
||||
return fixture().map((item) => ({
|
||||
...item,
|
||||
id: item.id + 12,
|
||||
filename: `more-${item.filename}`,
|
||||
url: `/media/more-${item.filename}`,
|
||||
}))
|
||||
}
|
||||
|
||||
export interface PickerRoutes {
|
||||
list?: (query: URLSearchParams) => unknown
|
||||
hash?: (hash: string) => unknown
|
||||
}
|
||||
|
||||
export const apiKit = { request: vi.fn() }
|
||||
|
||||
export function installApi(routes: PickerRoutes = {}) {
|
||||
apiKit.request = vi.fn(async (path: string) => {
|
||||
const url = new URL(path, 'http://localhost')
|
||||
const hash = url.searchParams.get('hash')
|
||||
if (hash !== null) {
|
||||
return (routes.hash ?? (() => pageBody([], null)))(hash)
|
||||
}
|
||||
return (routes.list ?? (() => pageBody()))(url.searchParams)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
|
||||
request: apiKit.request,
|
||||
})
|
||||
}
|
||||
|
||||
export function installEmptyLibrary(routes: PickerRoutes = {}) {
|
||||
installApi({ list: () => pageBody([], null), ...routes })
|
||||
}
|
||||
|
||||
export function listCalls() {
|
||||
return apiKit.request.mock.calls
|
||||
.map(([path]) => new URL(path as string, 'http://localhost'))
|
||||
.filter((url) => url.pathname === '/v1/media/' && !url.searchParams.has('hash'))
|
||||
.map((url) => url.searchParams)
|
||||
}
|
||||
|
||||
export function hashCalls() {
|
||||
return apiKit.request.mock.calls
|
||||
.map(([path]) => new URL(path as string, 'http://localhost'))
|
||||
.filter((url) => url.searchParams.has('hash'))
|
||||
.map((url) => url.searchParams.get('hash'))
|
||||
}
|
||||
|
||||
export const Host = defineComponent({
|
||||
components: { ImageUploader },
|
||||
props: {
|
||||
initialFile: { type: Object as () => File | null, default: null },
|
||||
inArticleIds: { type: Array as () => number[], default: () => [1, 4] },
|
||||
},
|
||||
emits: ['uploaded', 'inserted', 'close'],
|
||||
setup(_props, { emit }) {
|
||||
const open = ref(false)
|
||||
return {
|
||||
open,
|
||||
onUploaded: (item: MediaItem) => emit('uploaded', item),
|
||||
onInserted: (item: MediaItem, alt: string) => emit('inserted', item, alt),
|
||||
onClose: () => emit('close'),
|
||||
}
|
||||
},
|
||||
template: `<ImageUploader v-model:open="open" :initial-file="initialFile" :in-article-ids="inArticleIds" @uploaded="onUploaded" @inserted="onInserted" @close="onClose" />`,
|
||||
})
|
||||
|
||||
export function mountUploader(
|
||||
props: { initialFile?: File | null; inArticleIds?: number[] } = {},
|
||||
width = DESKTOP_WIDTH,
|
||||
) {
|
||||
setViewportWidth(width)
|
||||
setActivePinia(createPinia())
|
||||
return mount(Host, {
|
||||
props,
|
||||
global: { plugins: [uiPlugin] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
}
|
||||
|
||||
export async function openUploader(wrapper: ReturnType<typeof mountUploader>) {
|
||||
;(wrapper.vm as unknown as { open: boolean }).open = true
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function settle() {
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function tabButtons() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]'))
|
||||
}
|
||||
|
||||
export function tabButton(label: string) {
|
||||
return tabButtons().find((element) => textOf(element) === label)
|
||||
}
|
||||
|
||||
export function selectedTab() {
|
||||
return tabButtons().find((element) => element.getAttribute('aria-selected') === 'true')
|
||||
}
|
||||
|
||||
export function tiles() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]'))
|
||||
}
|
||||
|
||||
export function tile(filename: string) {
|
||||
return tiles().find((element) =>
|
||||
element.getAttribute('aria-label')?.startsWith(`${filename},`),
|
||||
)!
|
||||
}
|
||||
|
||||
export function skeletonTiles() {
|
||||
return document.body.querySelectorAll('[data-testid="picker-skeleton-tile"]')
|
||||
}
|
||||
|
||||
export function findButton(label: string) {
|
||||
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||
(button) => textOf(button) === label,
|
||||
)
|
||||
}
|
||||
|
||||
export async function click(element: Element | null | undefined) {
|
||||
;(element as HTMLElement).click()
|
||||
await settle()
|
||||
}
|
||||
|
||||
export async function clickTab(label: string) {
|
||||
tabButton(label)!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
await settle()
|
||||
}
|
||||
|
||||
export async function selectTile(filename: string) {
|
||||
await click(tile(filename))
|
||||
}
|
||||
|
||||
export function altField() {
|
||||
return document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="e.g. North wall of the kitchen before plastering"]',
|
||||
)
|
||||
}
|
||||
|
||||
export async function typeInto(input: HTMLInputElement, value: string) {
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
export function searchField() {
|
||||
return document.body.querySelector<HTMLInputElement>('input[aria-label="Search by filename"]')!
|
||||
}
|
||||
|
||||
export function dialogText() {
|
||||
return textOf(document.body.querySelector('[role="dialog"]'))
|
||||
}
|
||||
|
||||
export function modalOpen() {
|
||||
return document.body.querySelector('[role="dialog"]') !== null
|
||||
}
|
||||
|
||||
export function press(element: Element, key: string) {
|
||||
element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
}
|
||||
@@ -1,15 +1,9 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
|
||||
export interface ArticleImage {
|
||||
id: number
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
id: number
|
||||
title: string
|
||||
body: string
|
||||
images: ArticleImage[]
|
||||
is_online: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { ref } from 'vue'
|
||||
import { redirectToLogin, redirectUnverified } from '@/composables/useApi'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
|
||||
const UPLOAD_URL = '/v1/media/'
|
||||
const HTTP_CREATED = 201
|
||||
const HTTP_BAD_REQUEST = 400
|
||||
const HTTP_UNAUTHORIZED = 401
|
||||
const HTTP_FORBIDDEN = 403
|
||||
const HTTP_TOO_LARGE = 413
|
||||
const HTTP_UNSUPPORTED = 415
|
||||
|
||||
export interface UploadProgress {
|
||||
loaded: number
|
||||
@@ -6,10 +16,41 @@ export interface UploadProgress {
|
||||
}
|
||||
|
||||
export type UploadOutcome =
|
||||
| { kind: 'success'; location: string; id: number }
|
||||
| { kind: 'rejected'; body: string }
|
||||
| { kind: 'success'; item: MediaItem }
|
||||
| { kind: 'rejected'; reason: 'size' | 'type' }
|
||||
| { kind: 'handled' }
|
||||
| { kind: 'failed' }
|
||||
|
||||
function readItem(responseText: string): MediaItem | null {
|
||||
try {
|
||||
return JSON.parse(responseText) as MediaItem
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function outcomeOf(request: XMLHttpRequest): UploadOutcome {
|
||||
switch (request.status) {
|
||||
case HTTP_CREATED: {
|
||||
const item = readItem(request.responseText)
|
||||
return item ? { kind: 'success', item } : { kind: 'failed' }
|
||||
}
|
||||
case HTTP_TOO_LARGE:
|
||||
return { kind: 'rejected', reason: 'size' }
|
||||
case HTTP_UNSUPPORTED:
|
||||
case HTTP_BAD_REQUEST:
|
||||
return { kind: 'rejected', reason: 'type' }
|
||||
case HTTP_UNAUTHORIZED:
|
||||
redirectToLogin()
|
||||
return { kind: 'handled' }
|
||||
case HTTP_FORBIDDEN:
|
||||
redirectUnverified()
|
||||
return { kind: 'handled' }
|
||||
default:
|
||||
return { kind: 'failed' }
|
||||
}
|
||||
}
|
||||
|
||||
export function useImageUpload() {
|
||||
const progress = ref<UploadProgress>({ loaded: 0, total: 0 })
|
||||
let activeRequest: XMLHttpRequest | null = null
|
||||
@@ -19,31 +60,19 @@ export function useImageUpload() {
|
||||
activeRequest = null
|
||||
}
|
||||
|
||||
function start(articleId: number, file: File): Promise<UploadOutcome> {
|
||||
function start(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.open('POST', UPLOAD_URL)
|
||||
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 {
|
||||
id: number
|
||||
location: string
|
||||
}
|
||||
resolve({ kind: 'success', location: body.location, id: body.id })
|
||||
return
|
||||
}
|
||||
if (request.status === 400) {
|
||||
resolve({ kind: 'rejected', body: request.responseText })
|
||||
return
|
||||
}
|
||||
resolve({ kind: 'failed' })
|
||||
resolve(outcomeOf(request))
|
||||
})
|
||||
request.addEventListener('error', () => {
|
||||
activeRequest = null
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { ref } from 'vue'
|
||||
import { tryOnBeforeUnmount } from '@vueuse/core'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import {
|
||||
MEDIA_SKELETON_DELAY_MS,
|
||||
classifyMediaError,
|
||||
type MediaCounts,
|
||||
type MediaItem,
|
||||
} from '@/composables/useMedia'
|
||||
import { useSeenImagesStore } from '@/stores/seenImages'
|
||||
|
||||
export const LIBRARY_PAGE_LIMIT = 24
|
||||
|
||||
export type LibraryStatus = 'idle' | 'loading' | 'ready' | 'error' | 'handled'
|
||||
|
||||
interface LibraryResponse {
|
||||
items: MediaItem[]
|
||||
next_cursor: string | null
|
||||
counts: MediaCounts
|
||||
}
|
||||
|
||||
function requestQuery(query: string, cursor: string | null) {
|
||||
return [
|
||||
...(query ? [`q=${encodeURIComponent(query)}`] : []),
|
||||
`limit=${LIBRARY_PAGE_LIMIT}`,
|
||||
...(cursor ? [`cursor=${encodeURIComponent(cursor)}`] : []),
|
||||
].join('&')
|
||||
}
|
||||
|
||||
export function useLibraryList() {
|
||||
const { request } = useApi()
|
||||
const seen = useSeenImagesStore()
|
||||
|
||||
const items = ref<MediaItem[]>([])
|
||||
const total = ref(0)
|
||||
const nextCursor = ref<string | null>(null)
|
||||
const status = ref<LibraryStatus>('idle')
|
||||
const query = ref('')
|
||||
const showSkeleton = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const firstNewId = ref<number | null>(null)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let latest = 0
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
clearTimeout(skeletonTimer)
|
||||
skeletonTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startSkeletonTimer() {
|
||||
showSkeleton.value = false
|
||||
clearSkeletonTimer()
|
||||
skeletonTimer = setTimeout(() => {
|
||||
showSkeleton.value = true
|
||||
}, MEDIA_SKELETON_DELAY_MS)
|
||||
}
|
||||
|
||||
function stopSkeleton() {
|
||||
clearSkeletonTimer()
|
||||
showSkeleton.value = false
|
||||
}
|
||||
|
||||
function reset() {
|
||||
latest += 1
|
||||
items.value = []
|
||||
total.value = 0
|
||||
nextCursor.value = null
|
||||
status.value = 'idle'
|
||||
query.value = ''
|
||||
loadingMore.value = false
|
||||
firstNewId.value = null
|
||||
stopSkeleton()
|
||||
}
|
||||
|
||||
async function load(nextQuery = query.value) {
|
||||
latest += 1
|
||||
const ticket = latest
|
||||
query.value = nextQuery
|
||||
items.value = []
|
||||
nextCursor.value = null
|
||||
loadingMore.value = false
|
||||
firstNewId.value = null
|
||||
status.value = 'loading'
|
||||
startSkeletonTimer()
|
||||
try {
|
||||
const response = await request<LibraryResponse>(
|
||||
`/v1/media/?${requestQuery(nextQuery, null)}`,
|
||||
)
|
||||
if (ticket !== latest) {
|
||||
return
|
||||
}
|
||||
items.value = response.items
|
||||
total.value = response.counts.all
|
||||
nextCursor.value = response.next_cursor
|
||||
seen.remember(response.items)
|
||||
status.value = 'ready'
|
||||
} catch (error) {
|
||||
if (ticket !== latest) {
|
||||
return
|
||||
}
|
||||
status.value = classifyMediaError(error) === 'handled' ? 'handled' : 'error'
|
||||
} finally {
|
||||
if (ticket === latest) {
|
||||
stopSkeleton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (nextCursor.value === null || loadingMore.value || status.value !== 'ready') {
|
||||
return
|
||||
}
|
||||
const ticket = latest
|
||||
loadingMore.value = true
|
||||
startSkeletonTimer()
|
||||
try {
|
||||
const response = await request<LibraryResponse>(
|
||||
`/v1/media/?${requestQuery(query.value, nextCursor.value)}`,
|
||||
)
|
||||
if (ticket !== latest) {
|
||||
return
|
||||
}
|
||||
const known = new Set(items.value.map((item) => item.id))
|
||||
const added = response.items.filter((item) => !known.has(item.id))
|
||||
items.value = [...items.value, ...added]
|
||||
nextCursor.value = response.next_cursor
|
||||
firstNewId.value = added[0]?.id ?? null
|
||||
seen.remember(added)
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
if (ticket === latest) {
|
||||
loadingMore.value = false
|
||||
stopSkeleton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function search(nextQuery: string) {
|
||||
if (nextQuery === query.value && status.value !== 'idle') {
|
||||
return
|
||||
}
|
||||
await load(nextQuery)
|
||||
}
|
||||
|
||||
async function ensureLoaded() {
|
||||
if (status.value === 'idle') {
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
tryOnBeforeUnmount(clearSkeletonTimer)
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
nextCursor,
|
||||
status,
|
||||
query,
|
||||
showSkeleton,
|
||||
loadingMore,
|
||||
firstNewId,
|
||||
load,
|
||||
loadMore,
|
||||
search,
|
||||
ensureLoaded,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ function toneOf(name: string) {
|
||||
return Array.from(name).reduce((sum, letter) => sum + letter.charCodeAt(0), 0) % TONE_COUNT
|
||||
}
|
||||
|
||||
function oldestOf(items: MediaItem[]) {
|
||||
export function oldestOf(items: MediaItem[]) {
|
||||
return [...items].sort(
|
||||
(first, second) =>
|
||||
Date.parse(first.created_at) - Date.parse(second.created_at) || first.id - second.id,
|
||||
|
||||
@@ -61,6 +61,23 @@ describe('Selecting an inserted image reveals its delete button', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('The remove button is labelled and neutral', () => {
|
||||
it('reads "Remove image" with a trash icon and is not styled as destructive', async () => {
|
||||
const { wrapper, getEditor } = mountHarness()
|
||||
activeWrapper = wrapper
|
||||
const editor = getEditor()
|
||||
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
|
||||
editor.commands.setNodeSelection(findArticleImagePos(editor, 10) as number)
|
||||
await flush()
|
||||
|
||||
const button = deleteButton()!
|
||||
|
||||
expect(button.textContent?.trim()).toBe('Remove image')
|
||||
expect(button.className).not.toContain('text-error')
|
||||
expect(button.className).toContain('border-accented')
|
||||
})
|
||||
})
|
||||
|
||||
describe('An image with no data-image-id gets no delete affordance', () => {
|
||||
it('renders no delete button even when the node is selected', async () => {
|
||||
const { wrapper, getEditor } = mountHarness()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bodyImageIds } from '../bodyImageIds'
|
||||
|
||||
describe('bodyImageIds', () => {
|
||||
it('lists the ids of the images that carry data-image-id', () => {
|
||||
const body =
|
||||
'<p>a</p><img src="/a.jpg" alt="" data-image-id="1"><p>b</p><img data-image-id="4" src="/b.svg">'
|
||||
|
||||
expect(bodyImageIds(body)).toEqual([1, 4])
|
||||
})
|
||||
|
||||
it('ignores images without an id and non numeric ids', () => {
|
||||
const body =
|
||||
'<img src="/a.jpg"><img src="/b.jpg" data-image-id="abc"><img data-image-id="">'
|
||||
|
||||
expect(bodyImageIds(body)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns nothing for a body without images', () => {
|
||||
expect(bodyImageIds('<p>Hello</p>')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
const IMAGE_ID_SELECTOR = 'img[data-image-id]'
|
||||
const IMAGE_ID_ATTRIBUTE = 'data-image-id'
|
||||
|
||||
export function bodyImageCount(html: string): number {
|
||||
return new DOMParser().parseFromString(html, 'text/html').querySelectorAll('img').length
|
||||
}
|
||||
|
||||
export function bodyImageIds(html: string): number[] {
|
||||
const document = new DOMParser().parseFromString(html, 'text/html')
|
||||
return Array.from(document.querySelectorAll(IMAGE_ID_SELECTOR))
|
||||
.map((image) => Number.parseInt(image.getAttribute(IMAGE_ID_ATTRIBUTE) ?? '', 10))
|
||||
.filter((id) => !Number.isNaN(id))
|
||||
}
|
||||
@@ -76,3 +76,11 @@ export function joinNames(names: string[]) {
|
||||
}
|
||||
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
|
||||
}
|
||||
|
||||
export function libraryMatchText(
|
||||
filename: string,
|
||||
existing: { created_at: string; uploaded_by: string },
|
||||
) {
|
||||
const by = existing.uploaded_by ? ` by ${existing.uploaded_by}` : ''
|
||||
return `${filename} matches an image uploaded on ${formatMediaDate(existing.created_at)}${by}.`
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const UNDERLINE_TAB_TRIGGER =
|
||||
'h-9 px-0 py-0 data-[state=inactive]:text-toned in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:bottom-0 in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:h-0.5 max-md:h-11 max-md:text-[0.9375rem]'
|
||||
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useSeenImagesStore } from '../seenImages'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('seenImages store', () => {
|
||||
it('finds a remembered image by id', () => {
|
||||
const store = useSeenImagesStore()
|
||||
|
||||
store.remember([{ id: 1, filename: 'north-wall.jpg', size: 4089446, extra: true } as never])
|
||||
|
||||
expect(store.find(1)).toEqual({ id: 1, filename: 'north-wall.jpg', size: 4089446 })
|
||||
})
|
||||
|
||||
it('knows nothing about an image it has not seen', () => {
|
||||
expect(useSeenImagesStore().find(7)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the latest record of an image', () => {
|
||||
const store = useSeenImagesStore()
|
||||
store.remember([{ id: 1, filename: 'north-wall.jpg', size: 10 }])
|
||||
|
||||
store.remember([{ id: 1, filename: 'renamed.jpg', size: 10 }])
|
||||
|
||||
expect(store.find(1)?.filename).toBe('renamed.jpg')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface SeenImage {
|
||||
id: number
|
||||
filename: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export const useSeenImagesStore = defineStore('seenImages', () => {
|
||||
const byId = ref<Record<number, SeenImage>>({})
|
||||
|
||||
function remember(images: SeenImage[]) {
|
||||
for (const { id, filename, size } of images) {
|
||||
byId.value[id] = { id, filename, size }
|
||||
}
|
||||
}
|
||||
|
||||
function find(id: number): SeenImage | null {
|
||||
return byId.value[id] ?? null
|
||||
}
|
||||
|
||||
return { remember, find }
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import NewArticleModal from '@/components/NewArticleModal.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import { useArticles, type Article } from '@/composables/useArticles'
|
||||
import { bodyImageCount } from '@/lib/bodyImageIds'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const SEARCH_DEBOUNCE_MS = 120
|
||||
@@ -239,7 +240,7 @@ async function handleCopyUrl(id: number) {
|
||||
}
|
||||
|
||||
function deleteDescription(article: Article) {
|
||||
const count = article.images.length
|
||||
const count = bodyImageCount(article.body)
|
||||
const imagesPhrase =
|
||||
count === 0 ? '' : count === 1 ? ' and its image' : ` and its ${count} images`
|
||||
return `"${article.title}"${imagesPhrase} will be deleted. This can't be undone.`
|
||||
|
||||
@@ -15,6 +15,10 @@ import ImageUploader from '@/components/ImageUploader.vue'
|
||||
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { useArticleDraft } from '@/composables/useArticleDraft'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { bodyImageCount, bodyImageIds } from '@/lib/bodyImageIds'
|
||||
import { formatSize } from '@/lib/mediaFormat'
|
||||
import { useSeenImagesStore } from '@/stores/seenImages'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const BODY_MAX_LENGTH = 10000
|
||||
@@ -29,6 +33,7 @@ const toast = useToast()
|
||||
|
||||
const articleId = Number(route.params.id)
|
||||
const draft = useArticleDraft(articleId)
|
||||
const seenImages = useSeenImagesStore()
|
||||
|
||||
const loadState = ref<LoadState>('loading')
|
||||
const showSkeleton = ref(false)
|
||||
@@ -39,6 +44,7 @@ const publishPending = ref<string | undefined>(undefined)
|
||||
const editorRef = ref<InstanceType<typeof ArticleEditor> | null>(null)
|
||||
const imageUploaderOpen = ref(false)
|
||||
const imageUploaderFile = ref<File | null>(null)
|
||||
const imageIdsInArticle = ref<number[]>([])
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
@@ -159,7 +165,7 @@ function handleUnpublish() {
|
||||
}
|
||||
|
||||
function deleteDescription() {
|
||||
const count = article.value?.images.length ?? 0
|
||||
const count = bodyImageCount(article.value?.body ?? '')
|
||||
const imagesPhrase =
|
||||
count === 0 ? '' : count === 1 ? ' and its image' : ` and its ${count} images`
|
||||
return `"${article.value?.title ?? ''}"${imagesPhrase} will be deleted. This can't be undone.`
|
||||
@@ -206,6 +212,7 @@ async function handleDelete() {
|
||||
|
||||
function openImageUploader(file: File | null) {
|
||||
imageUploaderFile.value = file
|
||||
imageIdsInArticle.value = bodyImageIds(draft.body.value)
|
||||
imageUploaderOpen.value = true
|
||||
}
|
||||
|
||||
@@ -221,8 +228,7 @@ function onEditorDrop(event: DragEvent) {
|
||||
openImageUploader(file)
|
||||
}
|
||||
|
||||
function onImageUploaded(location: string, imageId: number) {
|
||||
editorRef.value?.insertImage(location, '', imageId)
|
||||
function announceImageAdded() {
|
||||
toast.add({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
@@ -231,31 +237,49 @@ function onImageUploaded(location: string, imageId: number) {
|
||||
})
|
||||
}
|
||||
|
||||
function onImageUploaded(item: MediaItem) {
|
||||
seenImages.remember([item])
|
||||
editorRef.value?.insertImage(item.url, '', item.id)
|
||||
announceImageAdded()
|
||||
}
|
||||
|
||||
function onImageInserted(item: MediaItem, alt: string) {
|
||||
seenImages.remember([item])
|
||||
editorRef.value?.insertImage(item.url, alt, item.id)
|
||||
announceImageAdded()
|
||||
}
|
||||
|
||||
async function performRemoveImage(imageId: number) {
|
||||
try {
|
||||
await request(`/v1/article/${articleId}/image/${imageId}`, { method: 'DELETE' })
|
||||
} catch {
|
||||
throw new Error("Couldn't remove the image, so it's still there. Try again in a moment.")
|
||||
}
|
||||
editorRef.value?.removeImage(imageId)
|
||||
toast.add({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Image removed',
|
||||
description: 'Save to keep this change.',
|
||||
description: "It's still in your library.",
|
||||
})
|
||||
}
|
||||
|
||||
function removalPreview(imageId: number) {
|
||||
const seen = seenImages.find(imageId)
|
||||
return {
|
||||
src: editorRef.value?.imageSrc(imageId) ?? '',
|
||||
filename: seen?.filename,
|
||||
size: seen ? formatSize(seen.size) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function onRequestRemoveImage(imageId: number) {
|
||||
await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Remove this image?',
|
||||
description: "This image will be removed from the article. This can't be undone.",
|
||||
title: 'Remove this image from the article?',
|
||||
description:
|
||||
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
|
||||
confirmLabel: 'Remove image',
|
||||
cancelLabel: 'Cancel',
|
||||
pendingLabel: 'Removing…',
|
||||
tone: 'danger',
|
||||
tone: 'neutral',
|
||||
preview: removalPreview(imageId),
|
||||
action: () => performRemoveImage(imageId),
|
||||
},
|
||||
})
|
||||
@@ -476,9 +500,10 @@ onBeforeRouteLeave(async () => {
|
||||
|
||||
<ImageUploader
|
||||
v-model:open="imageUploaderOpen"
|
||||
:article-id="articleId"
|
||||
:initial-file="imageUploaderFile"
|
||||
:in-article-ids="imageIdsInArticle"
|
||||
@uploaded="onImageUploaded"
|
||||
@inserted="onImageInserted"
|
||||
@close="onImageUploaderClosed"
|
||||
/>
|
||||
|
||||
|
||||
@@ -77,7 +77,6 @@ function sixArticleFixture(): Article[] {
|
||||
id: 1,
|
||||
title: 'How we price a bathroom renovation',
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: true,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: iso(2 * HOUR_MS),
|
||||
@@ -86,7 +85,6 @@ function sixArticleFixture(): Article[] {
|
||||
id: 2,
|
||||
title: 'Choosing lime plaster for old stone walls',
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: false,
|
||||
created_at: '2026-09-09T09:00:00Z',
|
||||
updated_at: iso(1 * DAY_MS),
|
||||
@@ -95,7 +93,6 @@ function sixArticleFixture(): Article[] {
|
||||
id: 3,
|
||||
title: 'Before and after: the Rue Verte kitchen',
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: true,
|
||||
created_at: '2026-08-28T09:00:00Z',
|
||||
updated_at: iso(3 * DAY_MS),
|
||||
@@ -103,12 +100,7 @@ function sixArticleFixture(): Article[] {
|
||||
{
|
||||
id: 4,
|
||||
title: 'Winter checklist for timber windows',
|
||||
body: '',
|
||||
images: [
|
||||
{ id: 10, url: '/i/10.png' },
|
||||
{ id: 11, url: '/i/11.png' },
|
||||
{ id: 12, url: '/i/12.png' },
|
||||
],
|
||||
body: '<img src="/i/10.png"><img src="/i/11.png"><img src="/i/12.png">',
|
||||
is_online: false,
|
||||
created_at: '2026-09-02T09:00:00Z',
|
||||
updated_at: iso(5 * DAY_MS),
|
||||
@@ -117,7 +109,6 @@ function sixArticleFixture(): Article[] {
|
||||
id: 5,
|
||||
title: 'Why we reuse original floorboards',
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: true,
|
||||
created_at: '2026-06-14T09:00:00Z',
|
||||
updated_at: '2026-08-18T09:00:00Z',
|
||||
@@ -126,7 +117,6 @@ function sixArticleFixture(): Article[] {
|
||||
id: 6,
|
||||
title: 'Our first passive-house retrofit',
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: true,
|
||||
created_at: '2026-04-03T09:00:00Z',
|
||||
updated_at: '2026-07-02T09:00:00Z',
|
||||
|
||||
@@ -10,6 +10,8 @@ import RootComponent from '@/App.vue'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
|
||||
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn(async () => 'a'.repeat(64)) }))
|
||||
|
||||
vi.mock('@/lib/imageCrop', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
|
||||
return {
|
||||
@@ -118,7 +120,6 @@ function articleFixture(overrides: Partial<Article> = {}): Article {
|
||||
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,
|
||||
@@ -186,29 +187,39 @@ afterEach(async () => {
|
||||
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()),
|
||||
})
|
||||
const EMPTY_LIBRARY = { items: [], next_cursor: null, counts: { all: 0, unused: 0, duplicates: 0 } }
|
||||
|
||||
function articleHandlers(extra: Record<string, () => Response | Promise<Response>> = {}) {
|
||||
return {
|
||||
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
||||
'GET /v1/media/?limit=24': () => mockResponse(200, EMPTY_LIBRARY),
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
describe("Opening the uploader from the toolbar's image button on an empty library shows Upload", () => {
|
||||
it('opens ImageUploader on the idle Upload tab and sends no POST', async () => {
|
||||
const wrapper = await mountEditor(articleHandlers())
|
||||
|
||||
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('Drop an image here, or choose a file')
|
||||
expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
|
||||
expect(findButton('Choose file')).toBeTruthy()
|
||||
expect(findButton('Cancel')).toBeTruthy()
|
||||
expect(findButton('Insert image')).toBeUndefined()
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dropping a valid file onto the editor opens the uploader already showing the crop step', () => {
|
||||
it('opens the crop step, and confirming it sends the resized file', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
||||
})
|
||||
describe('Dropping a valid file onto the editor opens the uploader on the crop step', () => {
|
||||
it('looks the hash up, skips the list, and uploads to /v1/media/ from the crop step', async () => {
|
||||
const wrapper = await mountEditor(
|
||||
articleHandlers({
|
||||
[`GET /v1/media/?hash=${'a'.repeat(64)}`]: () => mockResponse(200, EMPTY_LIBRARY),
|
||||
}),
|
||||
)
|
||||
|
||||
const file = fileFixture('before.png', 'image/png', 3.9)
|
||||
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
|
||||
@@ -219,22 +230,22 @@ describe('Dropping a valid file onto the editor opens the uploader already showi
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('Crop and resize')
|
||||
expect(document.body.textContent).not.toContain('Drop an image here')
|
||||
expect(vi.mocked(global.fetch).mock.calls.map(([url]) => url)).not.toContain(
|
||||
'/v1/media/?limit=24',
|
||||
)
|
||||
|
||||
findButton('Upload image')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
|
||||
expect(latestXHR().url).toBe('/v1/article/12/image')
|
||||
expect(latestXHR().url).toBe('/v1/media/')
|
||||
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()),
|
||||
})
|
||||
it('inserts the image with its id, marks the draft dirty and toasts', async () => {
|
||||
const wrapper = await mountEditor(articleHandlers())
|
||||
|
||||
await toolbarImageButton(wrapper)?.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -247,20 +258,25 @@ describe('A successful upload inserts the image at the cursor and marks the draf
|
||||
})
|
||||
fileInput.dispatchEvent(new Event('change'))
|
||||
await flushPromises()
|
||||
|
||||
findButton('Upload image')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
|
||||
latestXHR().resolve(
|
||||
201,
|
||||
JSON.stringify({ location: '/static/uploadedImages/north-wall.jpg' }),
|
||||
JSON.stringify({
|
||||
id: 13,
|
||||
filename: 'north-wall.jpg',
|
||||
url: '/static/uploadedImages/13.jpg',
|
||||
size: 4089446,
|
||||
}),
|
||||
)
|
||||
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(insertedBody).toContain(
|
||||
'<img src="/static/uploadedImages/13.jpg" alt="" data-image-id="13">',
|
||||
)
|
||||
expect(wrapper.text()).toContain('Unsaved changes')
|
||||
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import RootComponent from '@/App.vue'
|
||||
import { fixture } from './mediaTestKit'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
|
||||
const BODY_WITH_TWO_IMAGES =
|
||||
'<p>Intro</p><img src="/media/north-wall.jpg" alt="" data-image-id="1"><img src="/media/logo-renew.svg" alt="" data-image-id="4">'
|
||||
|
||||
function mockResponse(status: number, body: unknown = null) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: () => null },
|
||||
json: async () => body,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function articleFixture(body = BODY_WITH_TWO_IMAGES): Article {
|
||||
return {
|
||||
id: 12,
|
||||
title: 'How we price a bathroom renovation',
|
||||
body,
|
||||
is_online: false,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: FIXED_NOW,
|
||||
}
|
||||
}
|
||||
|
||||
function libraryPage() {
|
||||
const items = fixture().map((item) =>
|
||||
item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : item,
|
||||
)
|
||||
return { items, next_cursor: 'c24', counts: { all: 48, unused: 2, duplicates: 2 } }
|
||||
}
|
||||
|
||||
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(body = BODY_WITH_TWO_IMAGES) {
|
||||
setActivePinia(createPinia())
|
||||
toastAdd = vi.fn()
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.status = 'ready'
|
||||
global.fetch = vi.fn(async (url: string) => {
|
||||
if (url === '/v1/article/12') {
|
||||
return mockResponse(200, articleFixture(body))
|
||||
}
|
||||
if (url === '/v1/media/?limit=24') {
|
||||
return mockResponse(200, libraryPage())
|
||||
}
|
||||
throw new Error(`Unexpected fetch call: ${url}`)
|
||||
}) as unknown as typeof fetch
|
||||
await router.push('/article/12/edit')
|
||||
const wrapper = mount(RootComponent, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
currentWrapper = wrapper
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function findButton(text: string) {
|
||||
return Array.from(document.body.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === text,
|
||||
)
|
||||
}
|
||||
|
||||
async function openPicker(wrapper: Awaited<ReturnType<typeof mount>>) {
|
||||
await wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'Image')
|
||||
?.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function tile(filename: string) {
|
||||
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]')).find(
|
||||
(element) => element.getAttribute('aria-label')?.startsWith(`${filename},`),
|
||||
)!
|
||||
}
|
||||
|
||||
function taggedIds() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-picker-tile]'))
|
||||
.filter((element) => element.textContent?.includes('In this article'))
|
||||
.map((element) => element.getAttribute('data-image-id'))
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
currentWrapper?.unmount()
|
||||
currentWrapper = null
|
||||
document.body.innerHTML = ''
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('An image is in this article when its node is in the current content', () => {
|
||||
it('tags images 1 and 4 and no other when the dialog opens', async () => {
|
||||
const wrapper = await mountEditor()
|
||||
|
||||
await openPicker(wrapper)
|
||||
|
||||
expect(taggedIds()).toEqual(['1', '4'])
|
||||
})
|
||||
|
||||
it('counts an insertion that has not been saved yet', async () => {
|
||||
const wrapper = await mountEditor('<p>Intro</p>')
|
||||
wrapper.findComponent(ArticleEditor).vm.insertImage('/static/uploadedImages/2.webp', '', 2)
|
||||
await flushPromises()
|
||||
|
||||
await openPicker(wrapper)
|
||||
|
||||
expect(taggedIds()).toEqual(['2'])
|
||||
})
|
||||
|
||||
it('no longer tags an image that was removed from the body', async () => {
|
||||
const wrapper = await mountEditor()
|
||||
wrapper.findComponent(ArticleEditor).vm.removeImage(1)
|
||||
await flushPromises()
|
||||
|
||||
await openPicker(wrapper)
|
||||
|
||||
expect(taggedIds()).toEqual(['4'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Insert puts the chosen image in the body with its alt text', () => {
|
||||
it('inserts it at the cursor, marks the draft dirty, toasts and closes the dialog', async () => {
|
||||
const wrapper = await mountEditor('<p>Intro</p>')
|
||||
await openPicker(wrapper)
|
||||
tile('kitchen-after.webp').click()
|
||||
await flushPromises()
|
||||
const alt = document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="e.g. North wall of the kitchen before plastering"]',
|
||||
)!
|
||||
alt.value = 'A kitchen with new oak worktops and white tiles'
|
||||
alt.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
findButton('Insert image')?.click()
|
||||
await flushPromises()
|
||||
|
||||
const body = wrapper.findComponent(ArticleEditor).props('modelValue')
|
||||
expect(body).toContain(
|
||||
'<img src="/static/uploadedImages/2.webp" alt="A kitchen with new oak worktops and white tiles" data-image-id="2">',
|
||||
)
|
||||
expect(wrapper.text()).toContain('Unsaved changes')
|
||||
expect(
|
||||
(wrapper.find('[data-testid="save-button"]').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.',
|
||||
})
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
const methods = vi
|
||||
.mocked(global.fetch)
|
||||
.mock.calls.map(([, init]) => (init as RequestInit | undefined)?.method)
|
||||
expect(methods.every((method) => method === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves the content and the draft alone when the dialog is cancelled', async () => {
|
||||
const wrapper = await mountEditor('<p>Intro</p>')
|
||||
await openPicker(wrapper)
|
||||
tile('kitchen-after.webp').click()
|
||||
await flushPromises()
|
||||
|
||||
findButton('Cancel')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent(ArticleEditor).props('modelValue')).not.toContain('<img')
|
||||
expect(wrapper.text()).not.toContain('Unsaved changes')
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useSeenImagesStore } from '@/stores/seenImages'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
@@ -615,8 +616,13 @@ async function requestRemoveImage(wrapper: Awaited<ReturnType<typeof mount>>, im
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('Requesting removal of an inline image opens the confirm dialog', () => {
|
||||
it('opens ConfirmDialog with the exact copy', async () => {
|
||||
function requestCalls(wrapper: Awaited<ReturnType<typeof mount>>) {
|
||||
void wrapper
|
||||
return vi.mocked(global.fetch).mock.calls.map(([url, init]) => routeFor(url as string, init))
|
||||
}
|
||||
|
||||
describe('Requesting removal of an inline image opens the neutral confirm dialog', () => {
|
||||
it('opens ConfirmDialog with the exact copy and sends nothing', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
|
||||
})
|
||||
@@ -624,27 +630,76 @@ describe('Requesting removal of an inline image opens the confirm dialog', () =>
|
||||
await requestRemoveImage(wrapper)
|
||||
|
||||
const props = overlayMock.getProps() as Record<string, unknown>
|
||||
expect(props.title).toBe('Remove this image?')
|
||||
expect(props.title).toBe('Remove this image from the article?')
|
||||
expect(props.description).toBe(
|
||||
"This image will be removed from the article. This can't be undone.",
|
||||
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
|
||||
)
|
||||
expect(props.confirmLabel).toBe('Remove image')
|
||||
expect(props.cancelLabel).toBe('Cancel')
|
||||
expect(props.pendingLabel).toBe('Removing…')
|
||||
expect(props.tone).toBe('danger')
|
||||
expect(props.tone).toBe('neutral')
|
||||
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
|
||||
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
|
||||
})
|
||||
|
||||
it('previews the image the session has seen with its filename and size', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
|
||||
})
|
||||
useSeenImagesStore().remember([{ id: 10, filename: 'before.png', size: 4089446 }])
|
||||
|
||||
await requestRemoveImage(wrapper)
|
||||
|
||||
const props = overlayMock.getProps() as { preview: Record<string, string> }
|
||||
expect(props.preview).toEqual({
|
||||
src: '/static/uploadedImages/before.png',
|
||||
filename: 'before.png',
|
||||
size: '3.9 MB',
|
||||
})
|
||||
})
|
||||
|
||||
it('previews only the thumbnail for an image the session has not seen', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
|
||||
})
|
||||
|
||||
await requestRemoveImage(wrapper)
|
||||
|
||||
const props = overlayMock.getProps() as { preview: Record<string, string> }
|
||||
expect(props.preview).toEqual({
|
||||
src: '/static/uploadedImages/before.png',
|
||||
filename: undefined,
|
||||
size: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Confirming removes the image, marks the draft dirty, and shows a success toast', () => {
|
||||
it('removes the image from the content and reads Unsaved changes', async () => {
|
||||
describe('Cancelling the confirm changes nothing', () => {
|
||||
it('keeps the image and the draft state when the dialog resolves to cancel', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
|
||||
})
|
||||
await requestRemoveImage(wrapper)
|
||||
|
||||
overlayMock.resolveOpen(false)
|
||||
await flushPromises()
|
||||
|
||||
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
|
||||
expect(wrapper.text()).not.toContain('Unsaved changes')
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Confirming removes the image from the body only, marks the draft dirty and toasts', () => {
|
||||
it('removes the image from the content, reads Unsaved changes and sends no request', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
|
||||
'DELETE /v1/article/12/image/10': () => mockResponse(204),
|
||||
})
|
||||
await requestRemoveImage(wrapper)
|
||||
|
||||
const props = overlayMock.getProps() as { action: () => Promise<void> }
|
||||
await props.action()
|
||||
await flushPromises()
|
||||
|
||||
expect(articleEditorBody(wrapper)).not.toContain('data-image-id="10"')
|
||||
expect(wrapper.text()).toContain('Unsaved changes')
|
||||
@@ -653,55 +708,9 @@ describe('Confirming removes the image, marks the draft dirty, and shows a succe
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Image removed',
|
||||
description: 'Save to keep this change.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('A failed removal keeps the image and shows the dialog inline error', () => {
|
||||
it.each([
|
||||
{ name: 'a 404 response', handler: () => mockResponse(404) },
|
||||
{ name: 'a 500 response', handler: () => mockResponse(500) },
|
||||
{
|
||||
name: 'a network failure',
|
||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
||||
},
|
||||
])('rejects with the inline error message for $name', async ({ handler }) => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
|
||||
'DELETE /v1/article/12/image/10': handler,
|
||||
})
|
||||
await requestRemoveImage(wrapper)
|
||||
|
||||
const props = overlayMock.getProps() as { action: () => Promise<void> }
|
||||
await expect(props.action()).rejects.toThrow(
|
||||
"Couldn't remove the image, so it's still there. Try again in a moment.",
|
||||
)
|
||||
|
||||
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
|
||||
expect(wrapper.text()).not.toContain('Unsaved changes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Confirming a removal started by Backspace behaves exactly like confirming one started by the button', () => {
|
||||
it('removes the image, marks the draft dirty, and shows the same success toast', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
|
||||
'DELETE /v1/article/12/image/10': () => mockResponse(204),
|
||||
})
|
||||
await requestRemoveImage(wrapper)
|
||||
|
||||
const props = overlayMock.getProps() as { action: () => Promise<void> }
|
||||
await props.action()
|
||||
|
||||
expect(articleEditorBody(wrapper)).not.toContain('data-image-id="10"')
|
||||
expect(wrapper.text()).toContain('Unsaved changes')
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Image removed',
|
||||
description: 'Save to keep this change.',
|
||||
description: "It's still in your library.",
|
||||
})
|
||||
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+273
-5
@@ -186,10 +186,17 @@ const editorArticle = {
|
||||
updated_at: editorNow,
|
||||
}
|
||||
|
||||
const emptyLibrary = {
|
||||
items: [],
|
||||
next_cursor: null,
|
||||
counts: { all: 0, unused: 0, duplicates: 0 },
|
||||
}
|
||||
|
||||
function editorApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/article/12': { status: 200, body: editorArticle },
|
||||
'GET /v1/media/': { status: 200, body: emptyLibrary },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -384,7 +391,7 @@ async function freezeUploadProgress(page: Page, loaded: number, total: number) {
|
||||
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')) {
|
||||
if (url.includes('/v1/media')) {
|
||||
setTimeout(() => {
|
||||
const event = new Event('progress')
|
||||
Object.assign(event, { loaded, total, lengthComputable: true })
|
||||
@@ -1583,6 +1590,253 @@ const mediaStates: Record<string, VisualState> = {
|
||||
Offline: mediaState(mediaApi(), goOffline),
|
||||
}
|
||||
|
||||
const pickerIntro =
|
||||
'A bathroom is priced by what has to happen behind the walls before anything gets tiled.'
|
||||
const pickerOutro = 'We don’t add a margin on materials you choose yourself.'
|
||||
const invisibleImage = '/static/uploadedImages/blank.svg'
|
||||
const invisibleImageSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'
|
||||
const boardImageSvgFor = (width: number, height: number) =>
|
||||
[
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 672 378" preserveAspectRatio="xMidYMid slice">`,
|
||||
'<style>.tone{fill:#e8dfd1}.ink{color:#7d6d56}@media (prefers-color-scheme:dark){.tone{fill:#d4c5b0}}</style>',
|
||||
'<defs><pattern id="hatch" width="12" height="12" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">',
|
||||
'<rect width="1" height="12" fill="rgb(128 128 128 / 0.12)"/></pattern></defs>',
|
||||
'<rect class="tone" width="672" height="378"/>',
|
||||
'<rect fill="url(#hatch)" width="672" height="378"/>',
|
||||
'<g class="ink" opacity="0.55" transform="translate(316 160) scale(1.6667)" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">',
|
||||
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></g>',
|
||||
'</svg>',
|
||||
].join('')
|
||||
|
||||
const boardImageMaxWidth = 672
|
||||
const boardImageSideSpace = 32
|
||||
|
||||
function boardImageFor(page: Page) {
|
||||
const viewport = page.viewportSize()
|
||||
const width = Math.min(
|
||||
boardImageMaxWidth,
|
||||
(viewport?.width ?? boardImageMaxWidth) - boardImageSideSpace,
|
||||
)
|
||||
return boardImageSvgFor(width, Math.round((width * 9) / 16))
|
||||
}
|
||||
const boardImage = '/static/uploadedImages/board-image.svg'
|
||||
|
||||
function pickerBody(image: string | null, ids: number[] = []) {
|
||||
const nodes = ids.map(
|
||||
(id) => `<img src="${image ?? invisibleImage}" alt="" data-image-id="${id}">`,
|
||||
)
|
||||
return `<p>${pickerIntro}</p>${nodes.join('')}<p>${pickerOutro}</p>`
|
||||
}
|
||||
|
||||
const pickerCounts = { all: 48, unused: 2, duplicates: 2 }
|
||||
const pickerFirstPageCursor = 'c24'
|
||||
const pickerSearchTerm = 'wall'
|
||||
|
||||
function pickerList(url: URL) {
|
||||
if (url.searchParams.has('hash')) {
|
||||
return mediaList(mediaItems([1]), pickerCounts)
|
||||
}
|
||||
if (url.searchParams.has('q')) {
|
||||
return { items: [], next_cursor: null, counts: pickerCounts }
|
||||
}
|
||||
return { items: mediaItems(), next_cursor: pickerFirstPageCursor, counts: pickerCounts }
|
||||
}
|
||||
|
||||
function pickerApi(
|
||||
overrides: Record<string, ApiMock> = {},
|
||||
body = pickerBody(null, [1, 4]),
|
||||
): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': mediaSession,
|
||||
'GET /v1/article/12': { status: 200, body: { ...editorArticle, body } },
|
||||
'GET /v1/media/': { status: 200, bodyFor: pickerList },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function pickerState(
|
||||
steps?: (page: Page) => Promise<void>,
|
||||
api: Record<string, ApiMock> = pickerApi(),
|
||||
): VisualState {
|
||||
return {
|
||||
path: '/article/12/edit',
|
||||
api,
|
||||
steps: async (page) => {
|
||||
await stubEditorImages(page)
|
||||
await steps?.(page)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function openPickerTab(page: Page, name: 'Upload' | 'Library') {
|
||||
await openImageUploader(page)
|
||||
await page.getByRole('tab', { name, exact: true }).click()
|
||||
}
|
||||
|
||||
async function waitForPickerTiles(page: Page) {
|
||||
await page.locator('[data-picker-tile]').first().waitFor()
|
||||
}
|
||||
|
||||
async function selectPickerTile(page: Page, filename: string) {
|
||||
await waitForPickerTiles(page)
|
||||
await page.locator(`[data-picker-tile][aria-label^="${filename},"]`).click()
|
||||
await page.getByTestId('picker-panel').waitFor()
|
||||
}
|
||||
|
||||
async function insertFromLibrary(page: Page, filename: string) {
|
||||
await openImageUploader(page)
|
||||
await selectPickerTile(page, filename)
|
||||
await page.getByRole('button', { name: 'Insert image', exact: true }).click()
|
||||
await page.getByText('Save to keep it in the article.').first().waitFor()
|
||||
}
|
||||
|
||||
async function selectEditorImage(page: Page) {
|
||||
await page.locator('[data-testid="article-editor-image"] img').first().click()
|
||||
await page.getByRole('button', { name: 'Remove image', exact: true }).waitFor()
|
||||
}
|
||||
|
||||
async function stubEditorImages(page: Page) {
|
||||
await page.route('**/static/uploadedImages/blank.svg', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: invisibleImageSvg }),
|
||||
)
|
||||
await page.route('**/static/uploadedImages/board-image.svg', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: boardImageFor(page) }),
|
||||
)
|
||||
await page.reload()
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('#app')?.textContent ?? '').trim().length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
async function stubBoardImage(page: Page) {
|
||||
await page.route('**/media/north-wall.jpg', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: boardImageFor(page) }),
|
||||
)
|
||||
}
|
||||
|
||||
async function browseLibraryThenClose(page: Page) {
|
||||
await openImageUploader(page)
|
||||
await waitForPickerTiles(page)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.getByRole('dialog').waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
async function requestRemoveFromEditor(page: Page) {
|
||||
await browseLibraryThenClose(page)
|
||||
await selectEditorImage(page)
|
||||
await page.getByRole('button', { name: 'Remove image', exact: true }).click()
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
}
|
||||
|
||||
const simplifiedEditorReason =
|
||||
'The board draws a simplified editor (centred 680 px column, no toolbar, no details panel, fixed image placeholder) while the real editor is left-aligned with a toolbar, a taller header and a details panel, so the image block and page chrome sit elsewhere'
|
||||
const simplifiedEditorMaxPercent = 40
|
||||
|
||||
function withSimplifiedEditorException(state: VisualState): VisualState {
|
||||
return {
|
||||
...state,
|
||||
exception: {
|
||||
maxPercent: simplifiedEditorMaxPercent,
|
||||
reason: simplifiedEditorReason,
|
||||
approvedBy: 'user',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const pickerStates: Record<string, VisualState> = {
|
||||
'Upload tab': pickerState(async (page) => {
|
||||
await openPickerTab(page, 'Upload')
|
||||
}),
|
||||
'Upload tab: already in your library': {
|
||||
...pickerState(async (page) => {
|
||||
await openPickerTab(page, 'Upload')
|
||||
await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9))
|
||||
await page.getByText('Already in your library').waitFor()
|
||||
}),
|
||||
exception: {
|
||||
maxPercent: 20,
|
||||
reason: 'The board buttons collapse to about 22 px on mobile (flex:1 in a column) while the feature requires 44 px full-width stacked buttons, which makes the modal 31 px taller',
|
||||
approvedBy: 'user',
|
||||
},
|
||||
},
|
||||
'Library: loading': pickerState(
|
||||
async (page) => {
|
||||
await openImageUploader(page)
|
||||
await page.getByTestId('picker-skeleton-tile').first().waitFor()
|
||||
},
|
||||
pickerApi({ 'GET /v1/media/': { status: 200, hang: true } }),
|
||||
),
|
||||
'Library: nothing selected': pickerState(async (page) => {
|
||||
await openImageUploader(page)
|
||||
await waitForPickerTiles(page)
|
||||
}),
|
||||
'Library: selected': pickerState(async (page) => {
|
||||
await openImageUploader(page)
|
||||
await selectPickerTile(page, 'kitchen-after.webp')
|
||||
}),
|
||||
'Library: alt text entered': pickerState(async (page) => {
|
||||
await openImageUploader(page)
|
||||
await selectPickerTile(page, 'kitchen-after.webp')
|
||||
await page
|
||||
.getByPlaceholder('e.g. North wall of the kitchen before plastering')
|
||||
.fill('A kitchen with new oak worktops and white tiles')
|
||||
}),
|
||||
'Library: loading more': pickerState(async (page) => {
|
||||
await openImageUploader(page)
|
||||
await waitForPickerTiles(page)
|
||||
await page.route(/\/v1\/media\/\?.*cursor=/, () => undefined)
|
||||
await page.getByRole('button', { name: 'Load more', exact: true }).click()
|
||||
await page.getByTestId('picker-skeleton-tile').first().waitFor()
|
||||
}),
|
||||
'Library: empty': pickerState(
|
||||
async (page) => {
|
||||
await openPickerTab(page, 'Library')
|
||||
await page.getByText('Your library is empty').waitFor()
|
||||
},
|
||||
pickerApi({ 'GET /v1/media/': { status: 200, body: emptyLibrary } }),
|
||||
),
|
||||
'Library: no results': pickerState(async (page) => {
|
||||
await openImageUploader(page)
|
||||
await waitForPickerTiles(page)
|
||||
await page.getByLabel('Search by filename').fill(pickerSearchTerm)
|
||||
await page.getByText(`No images match "${pickerSearchTerm}"`).waitFor()
|
||||
}),
|
||||
'Library: failed to load': pickerState(
|
||||
async (page) => {
|
||||
await openImageUploader(page)
|
||||
await page.getByText("Couldn't load your library").waitFor()
|
||||
},
|
||||
pickerApi({ 'GET /v1/media/': { status: 500 } }),
|
||||
),
|
||||
Inserted: withSimplifiedEditorException(
|
||||
pickerState(
|
||||
async (page) => {
|
||||
await stubBoardImage(page)
|
||||
await page.getByText('behind the walls').click()
|
||||
await page.keyboard.press('End')
|
||||
await insertFromLibrary(page, 'north-wall.jpg')
|
||||
await page.waitForTimeout(400)
|
||||
await selectEditorImage(page)
|
||||
},
|
||||
pickerApi({}, pickerBody(null)),
|
||||
),
|
||||
),
|
||||
'Remove image: confirm': withSimplifiedEditorException(
|
||||
pickerState(requestRemoveFromEditor, pickerApi({}, pickerBody(boardImage, [1]))),
|
||||
),
|
||||
Removed: pickerState(
|
||||
async (page) => {
|
||||
await requestRemoveFromEditor(page)
|
||||
await page
|
||||
.getByRole('alertdialog')
|
||||
.getByRole('button', { name: 'Remove image', exact: true })
|
||||
.click()
|
||||
await page.getByText("It's still in your library.").first().waitFor()
|
||||
},
|
||||
pickerApi({}, pickerBody(boardImage, [1])),
|
||||
),
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
profile: {
|
||||
'Account menu': {
|
||||
@@ -2306,17 +2560,30 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: editorApi({
|
||||
'POST /v1/article/12/image': {
|
||||
'POST /v1/media/': {
|
||||
status: 201,
|
||||
body: { location: '/static/uploadedImages/north-wall.jpg' },
|
||||
body: {
|
||||
id: 13,
|
||||
filename: 'north-wall.jpg',
|
||||
url: '/static/uploadedImages/north-wall.jpg',
|
||||
size: mib(3.9),
|
||||
content_type: 'image/jpeg',
|
||||
width: 4032,
|
||||
height: 3024,
|
||||
sha256: 'a'.repeat(64),
|
||||
uploaded_by: 'Marta Alves',
|
||||
created_at: editorNow,
|
||||
used_in: 0,
|
||||
duplicate: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
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 openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Upload image' }).click()
|
||||
await page.getByText('Save to keep it in the article.').first().waitFor()
|
||||
await page.waitForTimeout(650)
|
||||
},
|
||||
@@ -2514,4 +2781,5 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
},
|
||||
media: mediaStates,
|
||||
'media-picker': pickerStates,
|
||||
}
|
||||
Reference in new issue
Block a user