Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
938263cd6a | ||
|
|
d4fcfb2bba |
No files matched your search
@@ -0,0 +1,136 @@
|
||||
Feature: Wiring the editor's image upload flow
|
||||
|
||||
As a verified RenewCMS owner writing an article
|
||||
I want the editor's image button (and dropping a file onto the editor) to open an upload
|
||||
modal that validates the file, shows progress, and inserts the result at my cursor
|
||||
So that I can add images to an article's body without leaving the editor, with clear
|
||||
feedback when a file is rejected or the upload fails
|
||||
|
||||
Background:
|
||||
Given EditorView.vue listens for ArticleEditor's "request-image" event and, on it, opens
|
||||
ImageUploader (a UModal titled "Add an image") with the current article's id
|
||||
And dropping a file directly onto the editor surface also opens ImageUploader, with that
|
||||
file already provided to it instead of an empty idle state
|
||||
And ImageUploader validates a chosen or dropped file in the browser, checking both its MIME
|
||||
type and its file extension against png/jpeg/webp/svg, and that its size is at most
|
||||
10 × 1024 × 1024 bytes, before sending anything
|
||||
And a file that passes validation is uploaded via a new useImageUpload composable, using
|
||||
XMLHttpRequest (not useApi) so upload progress is observable, as multipart/form-data with
|
||||
the file under the field name "file", to POST /v1/article/{articleId}/image, per
|
||||
features/upload_article_image.feature's already-implemented and already-mounted contract
|
||||
And a 201 response's {"location": "..."} is inserted into ArticleEditor's content at the
|
||||
current cursor position via an exposed insertImage(src, alt) method, with alt ""; this
|
||||
moves useArticleDraft's state to "dirty" the same way any other body edit does
|
||||
And closing the modal while an upload is in flight aborts the XMLHttpRequest
|
||||
And a 401 response is handled entirely by useApi's global handling
|
||||
(features/api_client_global_error_handling.feature) and is out of scope for new scenarios
|
||||
here; the same is true of a 404 (the article was deleted elsewhere), which the editor's
|
||||
existing not-found handling already covers (features/editor_core.feature)
|
||||
|
||||
Scenario: Opening the uploader from the toolbar's image button shows the idle state
|
||||
Given the user is on /article/12/edit with the editor focused
|
||||
When the user activates the toolbar's "Image" button
|
||||
Then ImageUploader opens titled "Add an image"
|
||||
And it shows "Drop an image here", a "Choose file" action, and the help text
|
||||
"PNG, JPEG, WEBP, SVG · max 10 MB"
|
||||
And its cancel action reads "Cancel"
|
||||
And no request has been sent
|
||||
|
||||
Scenario: Dragging a file over the editor surface shows the dragging-over state
|
||||
Given ImageUploader is open in the idle state
|
||||
When the user drags a file over the drop zone
|
||||
Then the drop zone reads "Drop to upload"
|
||||
|
||||
Scenario: Dropping a valid file onto the editor opens the uploader already uploading it
|
||||
Given the user is on /article/12/edit with the editor focused and ImageUploader is not open
|
||||
When the user drops a file named "before.png", content type "image/png", size 3.9 MB, onto
|
||||
the editor surface
|
||||
Then ImageUploader opens titled "Add an image"
|
||||
And POST /v1/article/12/image is sent with that file under the field name "file"
|
||||
And the uploading state is shown, not the idle state
|
||||
|
||||
Scenario Outline: Choosing a file the browser can reject never sends a request
|
||||
Given ImageUploader is open in the idle state for article 12
|
||||
When the user chooses a file named "<filename>", content type "<content_type>", size <size>
|
||||
Then no request is sent to POST /v1/article/12/image
|
||||
And the rejected state shows the title "\"<filename>\" can't be used"
|
||||
And its text reads "<description>"
|
||||
And its confirm action reads "Choose another file" and its cancel action reads "Cancel"
|
||||
|
||||
Examples:
|
||||
| filename | content_type | size | description |
|
||||
| plan.pdf | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. |
|
||||
| disguised.png | application/pdf | 1.2 MB | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. |
|
||||
| north-wall.tif | image/png | 14.2 MB | north-wall.tif is 14.2 MB. Images can be up to 10 MB. |
|
||||
|
||||
Scenario: "Choose another file" from the rejected state returns to idle
|
||||
Given ImageUploader is in the rejected state for a file named "plan.pdf"
|
||||
When the user activates "Choose another file"
|
||||
Then the idle state is shown again, with the file picker available
|
||||
|
||||
Scenario: A valid upload shows progress and its cancel action reads "Cancel upload"
|
||||
Given ImageUploader is open in the idle state for article 12
|
||||
When the user chooses a file named "north-wall.jpg", content type "image/jpeg", size 3.9 MB
|
||||
Then POST /v1/article/12/image is sent with that file under the field name "file"
|
||||
And the uploading state shows "north-wall.jpg 0%" and "Uploading 0.0 of 3.9 MB"
|
||||
And its cancel action reads "Cancel upload"
|
||||
When the upload reports 2.4 MB sent of 3.9 MB
|
||||
Then the uploading state shows "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB"
|
||||
|
||||
Scenario: Closing the dialog during an upload cancels it
|
||||
Given ImageUploader is uploading a file named "north-wall.jpg" to article 12
|
||||
When the user activates "Cancel upload" (or otherwise closes the modal)
|
||||
Then the in-flight XMLHttpRequest is aborted
|
||||
And the modal closes
|
||||
And the editor's body is unchanged and remains in its previous dirty/clean state
|
||||
|
||||
Scenario: A successful upload inserts the image at the cursor and marks the draft dirty
|
||||
Given the user is on /article/12/edit in the Saved state and ImageUploader is uploading a
|
||||
file named "north-wall.jpg" to article 12
|
||||
When POST /v1/article/12/image resolves 201 with {"location": "/static/uploadedImages/north-wall.jpg"}
|
||||
Then the uploading state briefly shows "Image added"
|
||||
And the image at "/static/uploadedImages/north-wall.jpg" is inserted into the editor's
|
||||
content at the cursor via insertImage
|
||||
And SaveStatus reads "Unsaved changes" and the Save button becomes enabled
|
||||
And a toast appears: color success, icon i-lucide-circle-check, title "Image added",
|
||||
description "Save to keep it in the article."
|
||||
And the modal closes 600 ms after the response resolves
|
||||
|
||||
Scenario Outline: A server-side upload failure keeps the article unchanged and offers a retry
|
||||
Given ImageUploader is uploading a file named "north-wall.jpg" to article 12
|
||||
When POST /v1/article/12/image <failure>
|
||||
Then the failed state shows the title "Upload failed"
|
||||
And its text reads "The server didn't accept the file. Your article hasn't changed."
|
||||
And its confirm action reads "Try again"
|
||||
And the editor's body is unchanged, and no image is inserted
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: "Try again" re-attempts the upload with the same file
|
||||
Given ImageUploader is in the failed state for a file named "north-wall.jpg", article 12,
|
||||
after a 500 response
|
||||
When the user activates "Try again"
|
||||
Then POST /v1/article/12/image is sent again with that same file under the field name "file"
|
||||
And the uploading state is shown
|
||||
|
||||
Scenario: A server 400 for a type the client should have caught shows the same rejected copy
|
||||
Given ImageUploader is uploading a file named "notes.pdf" to article 12 (a type that
|
||||
defensively passed the browser's own check, e.g. a MIME type the browser misreported)
|
||||
When POST /v1/article/12/image resolves 400
|
||||
Then the rejected state shows the title "\"notes.pdf\" can't be used"
|
||||
And its text reads "Use a PNG, JPEG, WEBP or SVG image, up to 10 MB."
|
||||
And its confirm action reads "Choose another file"
|
||||
And the editor's body is unchanged, and no image is inserted
|
||||
|
||||
Scenario: A server 400 for a size the client should have caught shows the same size-named copy
|
||||
Given ImageUploader is uploading a file named "north-wall.tif" to article 12 (a file that
|
||||
defensively passed the browser's own size check somehow, e.g. its reported size changed
|
||||
between selection and send)
|
||||
When POST /v1/article/12/image resolves 400
|
||||
Then the rejected state shows the title "\"north-wall.tif\" can't be used"
|
||||
And its text reads "north-wall.tif is 14.2 MB. Images can be up to 10 MB."
|
||||
And its confirm action reads "Choose another file"
|
||||
And the editor's body is unchanged, and no image is inserted
|
||||
@@ -0,0 +1,90 @@
|
||||
# QA: Wiring the editor's image upload flow
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `XMLHttpRequest` (a fake implementing `open`/`setRequestHeader`/`send`/`abort` and firing
|
||||
`upload.progress`, `load`, `error`) for `POST /v1/article/{id}/image`; useApi/fetch is not
|
||||
involved in this slice. Mount EditorPage inside DashboardShell at `/article/12/edit` with the
|
||||
editor_core.qa.md fixture article (id 12, title "How we price a bathroom renovation", is_online
|
||||
false), GET already resolved 200.
|
||||
|
||||
1. **Toolbar button opens the idle state** — click the toolbar's "Image" button; assert
|
||||
ImageUploader opens titled "Add an image", showing "Drop an image here", "Choose file",
|
||||
"PNG, JPEG, WEBP, SVG · max 10 MB", cancel "Cancel"; assert no XHR was created.
|
||||
2. **Dragging over shows "Drop to upload"** — with the uploader open idle, fire a `dragover` on
|
||||
the drop zone; assert the text changes to "Drop to upload".
|
||||
3. **Dropping a file on the editor opens the uploader already uploading** — with the uploader
|
||||
closed, dispatch a `drop` event with a file `before.png` (image/png, 3.9 MB) on the editor
|
||||
surface; assert ImageUploader opens and an XHR to `/v1/article/12/image` is created with that
|
||||
file, and the uploading state (not idle) is shown.
|
||||
4. **Rejected — Examples-driven on type, extension mismatch, and size** — for each of `plan.pdf`
|
||||
(application/pdf, 1.2 MB), `disguised.png` (application/pdf, 1.2 MB), and `north-wall.tif`
|
||||
(image/png, 14.2 MB), choose the file via the `UFileUpload`; assert no XHR is created and the
|
||||
rejected state shows the exact title/text/confirm/cancel copy from the feature's Examples.
|
||||
5. **"Choose another file" returns to idle** — from the rejected state, click "Choose another
|
||||
file"; assert the idle state re-renders.
|
||||
6. **Uploading shows live progress and "Cancel upload"** — choose `north-wall.jpg` (image/jpeg,
|
||||
3.9 MB); assert the XHR is created with that file under field `file`; assert cancel reads
|
||||
"Cancel upload"; fire an `upload.progress` event with `loaded: 2.4 * 1024 * 1024, total: 3.9 *
|
||||
1024 * 1024`; assert the text reads "north-wall.jpg 62%" and "Uploading 2.4 of 3.9 MB".
|
||||
7. **Closing during an upload aborts the XHR** — from the uploading state, click "Cancel
|
||||
upload"; assert the fake XHR's `abort` was called, the modal closes, and the editor's body
|
||||
and dirty state are unchanged from before the upload started.
|
||||
8. **A 201 inserts at the cursor and marks the draft dirty** — from the uploading state, resolve
|
||||
the fake XHR with status 201 and body `{"location": "/static/uploadedImages/north-wall.jpg"}`;
|
||||
assert `insertImage` (or the editor's exposed method) was called with that location; assert
|
||||
SaveStatus now reads "Unsaved changes" and Save is enabled; assert a toast appears with color
|
||||
success, icon `i-lucide-circle-check`, title "Image added", description "Save to keep it in
|
||||
the article."; advance fake timers 600 ms and assert the modal has closed.
|
||||
9. **A 500 or network failure shows "Upload failed" and keeps the article unchanged, Examples-
|
||||
driven** — for (a) the fake XHR firing `load` with status 500 and (b) firing `error`; assert
|
||||
the failed state shows title "Upload failed", text "The server didn't accept the file. Your
|
||||
article hasn't changed.", confirm "Try again"; assert no `insertImage` call and the editor's
|
||||
body/dirty state is unchanged.
|
||||
10. **"Try again" resends the same file** — from the failed state for `north-wall.jpg`, click
|
||||
"Try again"; assert a new XHR is created for `/v1/article/12/image` with the same file, and
|
||||
the uploading state is shown again.
|
||||
11. **A server 400 for a type the client should have caught shows the rejected copy** — from an
|
||||
uploading state for `notes.pdf`, resolve the fake XHR with status 400; assert the rejected
|
||||
state shows title "\"notes.pdf\" can't be used", text "Use a PNG, JPEG, WEBP or SVG image,
|
||||
up to 10 MB.", confirm "Choose another file"; assert no `insertImage` call.
|
||||
12. **A server 400 for a size the client should have caught shows the size-named rejected copy**
|
||||
— from an uploading state for `north-wall.tif`, resolve the fake XHR with status 400; assert
|
||||
the rejected state shows title "\"north-wall.tif\" can't be used", text "north-wall.tif is
|
||||
14.2 MB. Images can be up to 10 MB.", confirm "Choose another file"; assert no `insertImage`
|
||||
call.
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/screens/editor.md` states "Upload: choose", "Upload: in progress",
|
||||
"Upload: rejected", "Upload: inserted", and `docs/design/source/RenewCMS Screens Back-
|
||||
office.dc.html`. Run `task visual:check -- editor` for each state below; add each state's mock
|
||||
setup to `web/visual/states.ts` first, reusing editor_core.qa.md's fixture article (id 12, title
|
||||
"How we price a bathroom renovation", draft, "Today, 11:02", 3 images) plus the sample file data
|
||||
from the board: filename "north-wall.jpg", size 3.9 MB, progress 62% / 2.4 of 3.9 MB uploaded.
|
||||
|
||||
- [ ] Upload: choose — modal "Add an image", idle state, cancel "Cancel".
|
||||
- [ ] Upload: in progress — modal "Add an image", uploading "north-wall.jpg 62%" / "Uploading
|
||||
2.4 of 3.9 MB", cancel "Cancel upload".
|
||||
- [ ] Upload: rejected — modal "Add an image", rejected state for a sample rejected file per the
|
||||
board's script, cancel "Cancel", confirm "Choose another file".
|
||||
- [ ] Upload: inserted — toast "Image added" / "Save to keep it in the article.", editor status
|
||||
"draft", save "dirty".
|
||||
- [ ] Mobile (all of the above at 390×844): same modal layout constraints as other editor
|
||||
modals (leading arrow-left button, trailing ellipsis in the header behind it).
|
||||
- [ ] Keyboard/focus: the drop zone is reachable by Tab and Enter opens the file picker; focus
|
||||
moves into the modal on open and is trapped there; Esc during "Upload: in progress"
|
||||
behaves like "Cancel upload" (aborts, closes); the progress bar's `role="progressbar"` /
|
||||
`aria-valuenow` updates are announced without spamming on every progress tick.
|
||||
- [ ] Motion: modal open/close follows the standard 240 ms timing; the success state's 600 ms
|
||||
auto-close is not visually checked as a capture (it's a timing behavior, not a static
|
||||
state) — cover only via the Vitest fake-timer assertion above.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real POST round trip against a running server** — Vitest only mocks `XMLHttpRequest`;
|
||||
confirm a real upload (200/400/401/404/500) against `task dev` with a live SQLite file and the
|
||||
real `POST /v1/article/{id}/image` handler, since `features/upload_article_image.feature`'s
|
||||
httptest suite covers the handler in isolation but never through this SPA flow.
|
||||
- **Real drag-and-drop from the OS file picker** — jsdom's `drop` event is synthetic; confirm a
|
||||
real OS-level drag of an image file onto the editor in a real browser.
|
||||
@@ -8,6 +8,7 @@
|
||||
"@nuxt/ui": "^4.11.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
|
||||
"@vueuse/core": "14.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"shiki": "^4.4.3",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@nuxt/ui": "^4.11.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
|
||||
"@vueuse/core": "14.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"shiki": "^4.4.3",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useToast } from '@nuxt/ui/composables'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
|
||||
const props = defineProps<{ article: Article }>()
|
||||
|
||||
defineEmits<{ 'copy-url': [] }>()
|
||||
const emit = defineEmits<{ 'copy-url': [] }>()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
@@ -55,6 +58,28 @@ const apiNote = computed(() =>
|
||||
? 'Live. Your website can read this article.'
|
||||
: 'Returns 404 until the article is online.',
|
||||
)
|
||||
|
||||
async function copyUrl() {
|
||||
const url = `${window.location.origin}${publicUrl.value}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.add({
|
||||
id: `article-link-copied-${props.article.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Link copied',
|
||||
description: `The API URL for "${props.article.title}" is on your clipboard.`,
|
||||
})
|
||||
emit('copy-url')
|
||||
} catch {
|
||||
toast.add({
|
||||
id: `article-link-copy-failed-${props.article.id}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't copy the link",
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -89,7 +114,15 @@ const apiNote = computed(() =>
|
||||
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
|
||||
>
|
||||
<span class="flex-1">{{ publicUrl }}</span>
|
||||
<UIcon name="i-lucide-copy" class="size-3.5" />
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Copy the public API URL"
|
||||
class="size-5 p-0"
|
||||
@click="copyUrl"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
|
||||
</div>
|
||||
@@ -127,7 +160,15 @@ const apiNote = computed(() =>
|
||||
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
|
||||
>
|
||||
<span class="flex-1">{{ publicUrl }}</span>
|
||||
<UIcon name="i-lucide-copy" class="size-3.5" />
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Copy the public API URL"
|
||||
class="size-5 p-0"
|
||||
@click="copyUrl"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
|
||||
</div>
|
||||
|
||||
@@ -42,9 +42,7 @@ function currentBlockLabel(editor: Editor) {
|
||||
}
|
||||
|
||||
function isDefaultAlignment(editor: Editor) {
|
||||
return !['center', 'right', 'justify'].some((align) =>
|
||||
editor.isActive({ textAlign: align }),
|
||||
)
|
||||
return !['center', 'right', 'justify'].some((align) => editor.isActive({ textAlign: align }))
|
||||
}
|
||||
|
||||
function buildItems(editor: Editor): EditorToolbarItem[][] {
|
||||
@@ -72,8 +70,7 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
|
||||
{
|
||||
icon: 'i-lucide-pilcrow',
|
||||
label: 'Paragraph',
|
||||
onSelect: () =>
|
||||
editor.chain().focus().setParagraph().run(),
|
||||
onSelect: () => editor.chain().focus().setParagraph().run(),
|
||||
},
|
||||
{
|
||||
kind: 'heading',
|
||||
@@ -201,6 +198,9 @@ defineExpose({
|
||||
focus() {
|
||||
editorInstance.value?.commands.focus()
|
||||
},
|
||||
insertImage(src: string, alt: string) {
|
||||
editorInstance.value?.commands.setImage({ src, alt })
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -216,6 +216,7 @@ defineExpose({
|
||||
@update:model-value="onUpdate"
|
||||
>
|
||||
<span class="hidden">{{ captureEditor(editor) }}</span>
|
||||
<UEditorDragHandle :editor="editor" />
|
||||
<UEditorToolbar
|
||||
:editor="editor"
|
||||
:items="buildItems(editor)"
|
||||
@@ -225,9 +226,7 @@ defineExpose({
|
||||
<div class="col-start-1 row-start-3">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
<div
|
||||
class="col-start-2 row-span-2 row-start-2 hidden min-h-0 lg:block"
|
||||
>
|
||||
<div class="col-start-2 row-span-2 row-start-2 hidden min-h-0 lg:block">
|
||||
<slot name="aside" />
|
||||
</div>
|
||||
</UEditor>
|
||||
@@ -239,16 +238,16 @@ defineExpose({
|
||||
grid-row-start: 2;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 2.5rem 3rem;
|
||||
background: var(--ui-bg);
|
||||
}
|
||||
.article-editor :deep(.ProseMirror) {
|
||||
max-width: 42rem;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
max-width: 48rem;
|
||||
padding: 2.5rem 3rem;
|
||||
outline: none;
|
||||
}
|
||||
@media (max-width: 47.999rem) {
|
||||
.article-editor :deep([data-slot='content']) {
|
||||
.article-editor :deep(.ProseMirror) {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
|
||||
@@ -23,14 +24,27 @@ const router = useRouter()
|
||||
const columnWidths =
|
||||
'[&:nth-child(2)]:w-27.5 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-35 [&:nth-child(5)]:w-11'
|
||||
|
||||
const sorting = ref([{ id: 'updated_at', desc: true }])
|
||||
|
||||
const columns = [
|
||||
{ accessorKey: 'title', header: 'Title' },
|
||||
{ accessorKey: 'is_online', header: 'Status', size: 110 },
|
||||
{ accessorKey: 'created_at', header: 'Created', size: 130 },
|
||||
{ accessorKey: 'updated_at', header: 'Updated', size: 140 },
|
||||
{ accessorKey: 'title', header: 'Title', enableSorting: true },
|
||||
{ accessorKey: 'is_online', header: 'Status', size: 110, enableSorting: true },
|
||||
{ accessorKey: 'created_at', header: 'Created', size: 130, enableSorting: true },
|
||||
{ accessorKey: 'updated_at', header: 'Updated', size: 140, enableSorting: true },
|
||||
{ id: 'actions', header: '', size: 44 },
|
||||
]
|
||||
|
||||
function sortIcon(column: { getIsSorted: () => false | 'asc' | 'desc' }) {
|
||||
const sorted = column.getIsSorted()
|
||||
if (sorted === 'asc') {
|
||||
return 'i-lucide-arrow-up'
|
||||
}
|
||||
if (sorted === 'desc') {
|
||||
return 'i-lucide-arrow-down'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function editPath(article: Article) {
|
||||
return `/article/${article.id}/edit`
|
||||
}
|
||||
@@ -83,7 +97,7 @@ function actionsFor(article: Article) {
|
||||
]
|
||||
}
|
||||
|
||||
function onSelect(row: { original: Article }) {
|
||||
function onSelect(_event: Event, row: { original: Article }) {
|
||||
router.push(editPath(row.original))
|
||||
}
|
||||
</script>
|
||||
@@ -112,6 +126,7 @@ function onSelect(row: { original: Article }) {
|
||||
th: `px-3 ${columnWidths}`,
|
||||
td: `h-11 px-3 py-0 ${columnWidths}`,
|
||||
}"
|
||||
v-model:sorting="sorting"
|
||||
@select="onSelect"
|
||||
>
|
||||
<template #caption>
|
||||
@@ -120,6 +135,50 @@ function onSelect(row: { original: Article }) {
|
||||
</caption>
|
||||
</template>
|
||||
|
||||
<template #title-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="column.toggleSorting()"
|
||||
>
|
||||
Title
|
||||
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #is_online-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="column.toggleSorting()"
|
||||
>
|
||||
Status
|
||||
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #created_at-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="column.toggleSorting()"
|
||||
>
|
||||
Created
|
||||
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #updated_at-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="column.toggleSorting()"
|
||||
>
|
||||
Updated
|
||||
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #title-cell="{ row }">
|
||||
<RouterLink
|
||||
:to="editPath(row.original)"
|
||||
@@ -140,13 +199,6 @@ function onSelect(row: { original: Article }) {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #updated_at-header>
|
||||
<span class="inline-flex items-center gap-1 text-highlighted">
|
||||
Updated
|
||||
<UIcon name="i-lucide-arrow-down" class="size-3" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #created_at-cell="{ row }">
|
||||
<span class="text-muted">{{
|
||||
formatAbsoluteDateFromIso(row.original.created_at)
|
||||
|
||||
@@ -8,11 +8,13 @@ const props = withDefaults(
|
||||
min?: number
|
||||
warnAt?: number
|
||||
variant?: 'inline' | 'footer'
|
||||
plainText?: boolean
|
||||
}>(),
|
||||
{
|
||||
min: 0,
|
||||
warnAt: 0.9,
|
||||
variant: 'inline',
|
||||
plainText: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -34,11 +36,14 @@ const status = computed<'normal' | 'warn' | 'over' | 'under'>(() => {
|
||||
return 'normal'
|
||||
})
|
||||
|
||||
const overText = computed(() =>
|
||||
overBy.value === 1
|
||||
? '1 character over. Shorten the text or remove an image. Formatting counts too.'
|
||||
: `${formatThousands(overBy.value)} characters over. Shorten the text or remove an image. Formatting counts too.`,
|
||||
)
|
||||
const overText = computed(() => {
|
||||
const suffix = props.plainText
|
||||
? 'Shorten the text.'
|
||||
: 'Shorten the text or remove an image. Formatting counts too.'
|
||||
return overBy.value === 1
|
||||
? `1 character over. ${suffix}`
|
||||
: `${formatThousands(overBy.value)} characters over. ${suffix}`
|
||||
})
|
||||
|
||||
const warnText = 'Close to the limit. Images and formatting count too.'
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
|
||||
import AppBoot from '@/components/AppBoot.vue'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
import ErrorView from '@/components/ErrorView.vue'
|
||||
@@ -44,7 +45,22 @@ const navItems = computed(() =>
|
||||
|
||||
const routeTitle = computed(() => (route.meta.title as string) ?? '')
|
||||
const pageTitle = computed(() => props.mobileTitle ?? routeTitle.value)
|
||||
const sidebarCollapsed = ref(false)
|
||||
const sidebarCollapsed = useSidebarCollapsed()
|
||||
|
||||
const sidebarUi = computed(() => ({
|
||||
root: sidebarCollapsed.value ? 'min-w-14 px-1 py-3' : 'min-w-14 p-3',
|
||||
header: 'mb-3 h-10 gap-2.5 px-1.5',
|
||||
body: 'gap-0.5 p-0',
|
||||
footer: 'p-0',
|
||||
}))
|
||||
|
||||
const navUi = computed(() => ({
|
||||
list: 'flex flex-col gap-0.5',
|
||||
link: sidebarCollapsed.value
|
||||
? 'h-8 justify-center gap-0 px-0 text-sm'
|
||||
: 'h-8 gap-2.5 px-2.5 py-0 text-sm',
|
||||
linkLeadingIcon: 'size-4',
|
||||
}))
|
||||
const loggingOut = ref(false)
|
||||
|
||||
function onRetry() {
|
||||
@@ -75,12 +91,7 @@ async function onLogout() {
|
||||
:resizable="false"
|
||||
:default-size="14"
|
||||
:collapsed-size="3.5"
|
||||
:ui="{
|
||||
root: 'min-w-14 p-3',
|
||||
header: 'mb-3 h-10 gap-2.5 px-1.5',
|
||||
body: 'gap-0.5 p-0',
|
||||
footer: 'p-0',
|
||||
}"
|
||||
:ui="sidebarUi"
|
||||
>
|
||||
<template #header>
|
||||
<AppLogo :size="26" />
|
||||
@@ -97,11 +108,7 @@ async function onLogout() {
|
||||
:items="navItems"
|
||||
:collapsed="sidebarCollapsed"
|
||||
aria-label="Main"
|
||||
:ui="{
|
||||
list: 'flex flex-col gap-0.5',
|
||||
link: 'h-8 gap-2.5 px-2.5 py-0 text-sm',
|
||||
linkLeadingIcon: 'size-4',
|
||||
}"
|
||||
:ui="navUi"
|
||||
/>
|
||||
|
||||
<template #footer>
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
IMAGE_TYPE_REJECTION_TEXT,
|
||||
formatMegabytes,
|
||||
isSizeRejectionBody,
|
||||
rejectionTitle,
|
||||
sizeRejectionText,
|
||||
validateImageFile,
|
||||
} from '@/lib/imageUpload'
|
||||
import { useImageUpload } from '@/composables/useImageUpload'
|
||||
|
||||
const props = defineProps<{
|
||||
articleId: number
|
||||
initialFile?: File | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploaded: [location: string]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
type Phase = 'idle' | 'dragging-over' | 'uploading' | 'rejected' | 'failed'
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const phase = ref<Phase>('idle')
|
||||
const chosenFile = ref<File | null>(null)
|
||||
const pickedFile = ref<File | null>(null)
|
||||
const rejectionKind = ref<'type' | 'size' | null>(null)
|
||||
const succeeded = ref(false)
|
||||
|
||||
const { progress, start, abort } = useImageUpload()
|
||||
let closeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const uploadPercent = computed(() => {
|
||||
if (progress.value.total === 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.round((progress.value.loaded / progress.value.total) * 100)
|
||||
})
|
||||
|
||||
const rejectedTitle = computed(() =>
|
||||
chosenFile.value ? rejectionTitle(chosenFile.value.name) : '',
|
||||
)
|
||||
|
||||
const rejectedText = computed(() => {
|
||||
if (!chosenFile.value) {
|
||||
return ''
|
||||
}
|
||||
if (rejectionKind.value === 'size') {
|
||||
return sizeRejectionText(chosenFile.value.name, chosenFile.value.size)
|
||||
}
|
||||
return IMAGE_TYPE_REJECTION_TEXT
|
||||
})
|
||||
|
||||
const cancelLabel = computed(() => (phase.value === 'uploading' ? 'Cancel upload' : 'Cancel'))
|
||||
|
||||
const showCancel = computed(
|
||||
() => phase.value !== 'failed' && !(phase.value === 'uploading' && succeeded.value),
|
||||
)
|
||||
|
||||
function clearCloseTimer() {
|
||||
if (closeTimer !== null) {
|
||||
clearTimeout(closeTimer)
|
||||
closeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function runUpload(file: File) {
|
||||
const outcome = await start(props.articleId, file)
|
||||
if (outcome.kind === 'success') {
|
||||
succeeded.value = true
|
||||
emit('uploaded', outcome.location)
|
||||
closeTimer = setTimeout(() => {
|
||||
open.value = false
|
||||
}, 600)
|
||||
return
|
||||
}
|
||||
if (outcome.kind === 'rejected') {
|
||||
rejectionKind.value = isSizeRejectionBody(outcome.body) ? 'size' : 'type'
|
||||
phase.value = 'rejected'
|
||||
return
|
||||
}
|
||||
phase.value = 'failed'
|
||||
}
|
||||
|
||||
function handleFile(file: File) {
|
||||
chosenFile.value = file
|
||||
const result = validateImageFile(file)
|
||||
if (!result.ok) {
|
||||
rejectionKind.value = result.kind
|
||||
phase.value = 'rejected'
|
||||
return
|
||||
}
|
||||
rejectionKind.value = null
|
||||
succeeded.value = false
|
||||
phase.value = 'uploading'
|
||||
runUpload(file)
|
||||
}
|
||||
|
||||
function onFileChange() {
|
||||
if (pickedFile.value) {
|
||||
handleFile(pickedFile.value)
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver() {
|
||||
if (phase.value === 'idle') {
|
||||
phase.value = 'dragging-over'
|
||||
}
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
if (phase.value === 'dragging-over') {
|
||||
phase.value = 'idle'
|
||||
}
|
||||
}
|
||||
|
||||
function onDropOnZone() {
|
||||
if (phase.value === 'dragging-over') {
|
||||
phase.value = 'idle'
|
||||
}
|
||||
}
|
||||
|
||||
function chooseAnotherFile() {
|
||||
phase.value = 'idle'
|
||||
chosenFile.value = null
|
||||
pickedFile.value = null
|
||||
rejectionKind.value = null
|
||||
}
|
||||
|
||||
function tryAgain() {
|
||||
if (!chosenFile.value) {
|
||||
return
|
||||
}
|
||||
succeeded.value = false
|
||||
phase.value = 'uploading'
|
||||
runUpload(chosenFile.value)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
phase.value = 'idle'
|
||||
chosenFile.value = null
|
||||
pickedFile.value = null
|
||||
rejectionKind.value = null
|
||||
succeeded.value = false
|
||||
if (props.initialFile) {
|
||||
handleFile(props.initialFile)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (phase.value === 'uploading' && !succeeded.value) {
|
||||
abort()
|
||||
}
|
||||
clearCloseTimer()
|
||||
emit('close')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
v-model:open="open"
|
||||
title="Add an image"
|
||||
:ui="{
|
||||
content:
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-120 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
footer: 'px-6 py-5 sm:px-6',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div
|
||||
v-if="phase === 'idle' || phase === 'dragging-over'"
|
||||
data-testid="image-dropzone"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDropOnZone"
|
||||
>
|
||||
<UFileUpload
|
||||
v-model="pickedFile"
|
||||
data-testid="image-file-input"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
:multiple="false"
|
||||
layout="list"
|
||||
:reset="true"
|
||||
@change="onFileChange"
|
||||
>
|
||||
<template #label>
|
||||
{{ phase === 'dragging-over' ? 'Drop to upload' : 'Drop an image here' }}
|
||||
</template>
|
||||
<template #description>PNG, JPEG, WEBP, SVG · max 10 MB</template>
|
||||
<template #actions="{ open: openDialog }">
|
||||
<UButton color="neutral" variant="outline" @click="openDialog()">
|
||||
Choose file
|
||||
</UButton>
|
||||
</template>
|
||||
</UFileUpload>
|
||||
</div>
|
||||
|
||||
<div v-else-if="phase === 'uploading'" class="flex flex-col gap-3">
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 rounded-md border border-default px-6 py-8 text-center"
|
||||
>
|
||||
<UIcon
|
||||
:name="succeeded ? 'i-lucide-circle-check' : 'i-lucide-image-plus'"
|
||||
:class="succeeded ? 'size-6 text-success' : 'size-6 text-dimmed'"
|
||||
/>
|
||||
<p class="text-sm text-highlighted">
|
||||
<template v-if="succeeded">Image added</template>
|
||||
<template v-else>{{ chosenFile?.name }} {{ uploadPercent }}%</template>
|
||||
</p>
|
||||
<UProgress
|
||||
v-if="!succeeded"
|
||||
:model-value="uploadPercent"
|
||||
size="xs"
|
||||
class="w-full"
|
||||
role="progressbar"
|
||||
:aria-valuenow="uploadPercent"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!succeeded" class="text-sm text-toned">
|
||||
Uploading {{ formatMegabytes(progress.loaded) }} of
|
||||
{{ formatMegabytes(progress.total) }} MB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="phase === 'rejected'" class="flex flex-col gap-1">
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 rounded-md border border-error px-6 py-8 text-center"
|
||||
>
|
||||
<UIcon name="i-lucide-file-x" class="size-6 text-error" />
|
||||
<p class="text-sm font-semibold text-highlighted">{{ rejectedTitle }}</p>
|
||||
<p class="text-sm text-toned">{{ rejectedText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="phase === 'failed'" class="flex flex-col gap-1">
|
||||
<p class="text-sm font-semibold text-highlighted">Upload failed</p>
|
||||
<p class="text-sm text-toned">
|
||||
The server didn't accept the file. Your article hasn't changed.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton v-if="showCancel" color="neutral" variant="outline" @click="cancel">
|
||||
{{ cancelLabel }}
|
||||
</UButton>
|
||||
<UButton v-if="phase === 'rejected'" @click="chooseAnotherFile">
|
||||
Choose another file
|
||||
</UButton>
|
||||
<UButton v-if="phase === 'failed'" @click="tryAgain"> Try again </UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -179,6 +179,7 @@ function onCancel() {
|
||||
:value="titleLength"
|
||||
:max="TITLE_MAX_LENGTH"
|
||||
:min="titleCounterMin"
|
||||
plain-text
|
||||
/>
|
||||
</template>
|
||||
<template v-if="titleError" #error="{ error }">
|
||||
|
||||
@@ -63,8 +63,11 @@ const items = computed(() => [
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
block
|
||||
class="h-10 justify-start gap-2.5 px-1.5 font-normal text-highlighted"
|
||||
trailing-icon="i-lucide-chevrons-up-down"
|
||||
:class="[
|
||||
'h-10 gap-2.5 px-1.5 font-normal text-highlighted',
|
||||
collapsed ? 'justify-center' : 'justify-start',
|
||||
]"
|
||||
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
|
||||
:ui="{ trailingIcon: 'ms-auto size-3.5 text-default' }"
|
||||
:aria-label="triggerAriaLabel"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import ImageUploader from '../ImageUploader.vue'
|
||||
import * as imageUpload from '@/lib/imageUpload'
|
||||
|
||||
vi.mock('@/lib/imageUpload', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/imageUpload')>()
|
||||
return {
|
||||
...actual,
|
||||
validateImageFile: vi.fn(actual.validateImageFile),
|
||||
}
|
||||
})
|
||||
|
||||
class FakeXMLHttpRequest {
|
||||
static instances: FakeXMLHttpRequest[] = []
|
||||
|
||||
method = ''
|
||||
url = ''
|
||||
status = 0
|
||||
responseText = ''
|
||||
withCredentials = false
|
||||
sentBody: FormData | null = null
|
||||
aborted = false
|
||||
|
||||
private listeners: Record<string, Array<(event?: unknown) => void>> = {}
|
||||
private uploadListeners: Record<string, (event: { loaded: number; total: number }) => void> = {}
|
||||
|
||||
upload = {
|
||||
addEventListener: (
|
||||
type: string,
|
||||
cb: (event: { loaded: number; total: number }) => void,
|
||||
) => {
|
||||
this.uploadListeners[type] = cb
|
||||
},
|
||||
}
|
||||
|
||||
constructor() {
|
||||
FakeXMLHttpRequest.instances.push(this)
|
||||
}
|
||||
|
||||
open(method: string, url: string) {
|
||||
this.method = method
|
||||
this.url = url
|
||||
}
|
||||
|
||||
send(body: FormData) {
|
||||
this.sentBody = body
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.aborted = true
|
||||
this.dispatch('abort')
|
||||
}
|
||||
|
||||
addEventListener(type: string, cb: (event?: unknown) => void) {
|
||||
this.listeners[type] = this.listeners[type] || []
|
||||
this.listeners[type].push(cb)
|
||||
}
|
||||
|
||||
dispatch(type: string, event?: unknown) {
|
||||
;(this.listeners[type] || []).forEach((cb) => cb(event))
|
||||
}
|
||||
|
||||
fireProgress(loaded: number, total: number) {
|
||||
this.uploadListeners.progress?.({ loaded, total })
|
||||
}
|
||||
|
||||
resolve(status: number, body: string) {
|
||||
this.status = status
|
||||
this.responseText = body
|
||||
this.dispatch('load')
|
||||
}
|
||||
|
||||
fail() {
|
||||
this.dispatch('error')
|
||||
}
|
||||
}
|
||||
|
||||
function latestXHR() {
|
||||
return FakeXMLHttpRequest.instances[FakeXMLHttpRequest.instances.length - 1]
|
||||
}
|
||||
|
||||
function mib(value: number) {
|
||||
return Math.round(value * 1024 * 1024)
|
||||
}
|
||||
|
||||
function fileFixture(name: string, type: string, sizeMB: number) {
|
||||
const file = new File(['x'], name, { type })
|
||||
Object.defineProperty(file, 'size', { value: mib(sizeMB), configurable: true })
|
||||
return file
|
||||
}
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { ImageUploader },
|
||||
props: {
|
||||
articleId: { type: Number, required: true },
|
||||
initialFile: { type: Object as () => File | null, default: null },
|
||||
},
|
||||
emits: ['uploaded', 'close'],
|
||||
setup(_props, { emit }) {
|
||||
const open = ref(false)
|
||||
return {
|
||||
open,
|
||||
onUploaded: (location: string) => emit('uploaded', location),
|
||||
onClose: () => emit('close'),
|
||||
}
|
||||
},
|
||||
template: `<ImageUploader v-model:open="open" :article-id="articleId" :initial-file="initialFile" @uploaded="onUploaded" @close="onClose" />`,
|
||||
})
|
||||
|
||||
function mountUploader(props: { articleId?: number; initialFile?: File | null } = {}) {
|
||||
return mount(Host, {
|
||||
props: { articleId: 12, initialFile: null, ...props },
|
||||
global: { plugins: [uiPlugin] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
}
|
||||
|
||||
async function openModal(wrapper: ReturnType<typeof mountUploader>) {
|
||||
;(wrapper.vm as unknown as { open: boolean }).open = true
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function fileInput() {
|
||||
return document.body.querySelector<HTMLInputElement>('[data-testid="image-file-input"]')!
|
||||
}
|
||||
|
||||
function dropZone() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="image-dropzone"]')!
|
||||
}
|
||||
|
||||
async function chooseFile(file: File) {
|
||||
const input = fileInput()
|
||||
Object.defineProperty(input, 'files', {
|
||||
value: { 0: file, length: 1 },
|
||||
configurable: true,
|
||||
})
|
||||
input.dispatchEvent(new Event('change'))
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function findButton(text: string) {
|
||||
return Array.from(document.body.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === text,
|
||||
)
|
||||
}
|
||||
|
||||
async function clickButton(text: string) {
|
||||
findButton(text)?.click()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
const RealXMLHttpRequest = global.XMLHttpRequest
|
||||
|
||||
beforeEach(() => {
|
||||
FakeXMLHttpRequest.instances = []
|
||||
global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
global.XMLHttpRequest = RealXMLHttpRequest
|
||||
})
|
||||
|
||||
describe('Opening the uploader from the toolbar image button shows the idle state', () => {
|
||||
it('shows the idle copy and sends no request', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
|
||||
expect(document.body.textContent).toContain('Add an image')
|
||||
expect(document.body.textContent).toContain('Drop an image here')
|
||||
expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
|
||||
expect(findButton('Choose file')).toBeTruthy()
|
||||
expect(findButton('Cancel')).toBeTruthy()
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dragging a file over the drop zone shows the dragging-over state', () => {
|
||||
it('shows "Drop to upload"', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
|
||||
dropZone().dispatchEvent(new Event('dragover', { bubbles: true, cancelable: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.textContent).toContain('Drop to upload')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Choosing a file the browser can reject never sends a request', () => {
|
||||
it.each([
|
||||
{
|
||||
filename: 'plan.pdf',
|
||||
contentType: 'application/pdf',
|
||||
sizeMB: 1.2,
|
||||
description: 'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
|
||||
},
|
||||
{
|
||||
filename: 'disguised.png',
|
||||
contentType: 'application/pdf',
|
||||
sizeMB: 1.2,
|
||||
description: 'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
|
||||
},
|
||||
{
|
||||
filename: 'north-wall.tif',
|
||||
contentType: 'image/png',
|
||||
sizeMB: 14.2,
|
||||
description: 'north-wall.tif is 14.2 MB. Images can be up to 10 MB.',
|
||||
},
|
||||
])(
|
||||
'rejects $filename with the exact copy',
|
||||
async ({ filename, contentType, sizeMB, description }) => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
|
||||
await chooseFile(fileFixture(filename, contentType, sizeMB))
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
|
||||
expect(document.body.textContent).toContain(`"${filename}" can't be used`)
|
||||
expect(document.body.textContent).toContain(description)
|
||||
expect(findButton('Choose another file')).toBeTruthy()
|
||||
expect(findButton('Cancel')).toBeTruthy()
|
||||
wrapper.unmount()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('"Choose another file" from the rejected state returns to idle', () => {
|
||||
it('shows the idle state again', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(fileFixture('plan.pdf', 'application/pdf', 1.2))
|
||||
|
||||
await clickButton('Choose another file')
|
||||
|
||||
expect(document.body.textContent).toContain('Drop an image here')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A valid upload shows progress and its cancel action reads "Cancel upload"', () => {
|
||||
it('sends the file and reports live progress', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
|
||||
const xhr = latestXHR()
|
||||
expect(xhr.url).toBe('/v1/article/12/image')
|
||||
expect(xhr.sentBody?.get('file')).toBeInstanceOf(File)
|
||||
expect(document.body.textContent).toContain('north-wall.jpg 0%')
|
||||
expect(document.body.textContent).toContain('Uploading 0.0 of 3.9 MB')
|
||||
expect(findButton('Cancel upload')).toBeTruthy()
|
||||
|
||||
xhr.fireProgress(mib(2.4), mib(3.9))
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.textContent).toContain('north-wall.jpg 62%')
|
||||
expect(document.body.textContent).toContain('Uploading 2.4 of 3.9 MB')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Closing the dialog during an upload cancels it', () => {
|
||||
it('aborts the in-flight XMLHttpRequest and closes the modal', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
const xhr = latestXHR()
|
||||
|
||||
await clickButton('Cancel upload')
|
||||
|
||||
expect(xhr.aborted).toBe(true)
|
||||
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A successful upload emits the location and auto-closes 600 ms later', () => {
|
||||
it('shows "Image added" then closes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
const xhr = latestXHR()
|
||||
|
||||
xhr.resolve(201, JSON.stringify({ location: '/static/uploadedImages/north-wall.jpg' }))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(wrapper.emitted('uploaded')).toEqual([['/static/uploadedImages/north-wall.jpg']])
|
||||
expect(document.body.textContent).toContain('Image added')
|
||||
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(590)
|
||||
|
||||
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
|
||||
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A server-side upload failure keeps the article unchanged and offers a retry', () => {
|
||||
it.each([
|
||||
{ name: 'a 500 response', trigger: (xhr: FakeXMLHttpRequest) => xhr.resolve(500, '') },
|
||||
{ name: 'a network failure', trigger: (xhr: FakeXMLHttpRequest) => xhr.fail() },
|
||||
])('shows the failed state for $name', async ({ trigger }) => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
const xhr = latestXHR()
|
||||
|
||||
trigger(xhr)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('Upload failed')
|
||||
expect(document.body.textContent).toContain(
|
||||
"The server didn't accept the file. Your article hasn't changed.",
|
||||
)
|
||||
expect(findButton('Try again')).toBeTruthy()
|
||||
expect(wrapper.emitted('uploaded')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('"Try again" re-attempts the upload with the same file', () => {
|
||||
it('resends the same file under field "file"', async () => {
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||
latestXHR().resolve(500, '')
|
||||
await flushPromises()
|
||||
|
||||
await clickButton('Try again')
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(2)
|
||||
const xhr = latestXHR()
|
||||
expect(xhr.url).toBe('/v1/article/12/image')
|
||||
expect((xhr.sentBody?.get('file') as File).name).toBe('north-wall.jpg')
|
||||
expect(document.body.textContent).toContain('north-wall.jpg 0%')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A server 400 for a type the client should have caught shows the same rejected copy', () => {
|
||||
it('shows the type-rejection copy', async () => {
|
||||
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(fileFixture('notes.pdf', 'image/png', 1.2))
|
||||
const xhr = latestXHR()
|
||||
|
||||
xhr.resolve(400, 'the file must be a PNG, JPEG, WEBP, or SVG image')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain(`"notes.pdf" can't be used`)
|
||||
expect(document.body.textContent).toContain(
|
||||
'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
|
||||
)
|
||||
expect(findButton('Choose another file')).toBeTruthy()
|
||||
expect(wrapper.emitted('uploaded')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A server 400 for a size the client should have caught shows the size-named copy', () => {
|
||||
it('shows the size-rejection copy computed from the file', async () => {
|
||||
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
|
||||
const wrapper = mountUploader()
|
||||
await openModal(wrapper)
|
||||
await chooseFile(fileFixture('north-wall.tif', 'image/png', 14.2))
|
||||
const xhr = latestXHR()
|
||||
|
||||
xhr.resolve(400, 'the file exceeds the maximum upload size')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain(`"north-wall.tif" can't be used`)
|
||||
expect(document.body.textContent).toContain(
|
||||
'north-wall.tif is 14.2 MB. Images can be up to 10 MB.',
|
||||
)
|
||||
expect(findButton('Choose another file')).toBeTruthy()
|
||||
expect(wrapper.emitted('uploaded')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface UploadProgress {
|
||||
loaded: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type UploadOutcome =
|
||||
| { kind: 'success'; location: string }
|
||||
| { kind: 'rejected'; body: string }
|
||||
| { kind: 'failed' }
|
||||
|
||||
export function useImageUpload() {
|
||||
const progress = ref<UploadProgress>({ loaded: 0, total: 0 })
|
||||
let activeRequest: XMLHttpRequest | null = null
|
||||
|
||||
function abort() {
|
||||
activeRequest?.abort()
|
||||
activeRequest = null
|
||||
}
|
||||
|
||||
function start(articleId: number, file: File): Promise<UploadOutcome> {
|
||||
progress.value = { loaded: 0, total: file.size }
|
||||
return new Promise((resolve) => {
|
||||
const request = new XMLHttpRequest()
|
||||
activeRequest = request
|
||||
request.open('POST', `/v1/article/${articleId}/image`)
|
||||
request.withCredentials = true
|
||||
request.upload.addEventListener('progress', (event) => {
|
||||
progress.value = { loaded: event.loaded, total: event.total }
|
||||
})
|
||||
request.addEventListener('load', () => {
|
||||
activeRequest = null
|
||||
if (request.status === 201) {
|
||||
const body = JSON.parse(request.responseText) as { location: string }
|
||||
resolve({ kind: 'success', location: body.location })
|
||||
return
|
||||
}
|
||||
if (request.status === 400) {
|
||||
resolve({ kind: 'rejected', body: request.responseText })
|
||||
return
|
||||
}
|
||||
resolve({ kind: 'failed' })
|
||||
})
|
||||
request.addEventListener('error', () => {
|
||||
activeRequest = null
|
||||
resolve({ kind: 'failed' })
|
||||
})
|
||||
request.addEventListener('abort', () => {
|
||||
activeRequest = null
|
||||
})
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
request.send(formData)
|
||||
})
|
||||
}
|
||||
|
||||
return { progress, start, abort }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
export function useSidebarCollapsed() {
|
||||
return useStorage('sidebar-collapsed', false)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
export const ALLOWED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
|
||||
|
||||
export const ALLOWED_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.svg']
|
||||
|
||||
export const IMAGE_TYPE_REJECTION_TEXT = 'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.'
|
||||
|
||||
export type ImageRejectionKind = 'type' | 'size'
|
||||
|
||||
export type ImageValidationResult = { ok: true } | { ok: false; kind: ImageRejectionKind }
|
||||
|
||||
export function fileExtension(filename: string) {
|
||||
const index = filename.lastIndexOf('.')
|
||||
return index === -1 ? '' : filename.slice(index).toLowerCase()
|
||||
}
|
||||
|
||||
export function validateImageFile(file: File): ImageValidationResult {
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
return { ok: false, kind: 'size' }
|
||||
}
|
||||
const typeAllowed = ALLOWED_IMAGE_MIME_TYPES.includes(file.type)
|
||||
const extensionAllowed = ALLOWED_IMAGE_EXTENSIONS.includes(fileExtension(file.name))
|
||||
if (!typeAllowed || !extensionAllowed) {
|
||||
return { ok: false, kind: 'type' }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export function formatMegabytes(bytes: number) {
|
||||
return (bytes / (1024 * 1024)).toFixed(1)
|
||||
}
|
||||
|
||||
export function isSizeRejectionBody(body: string) {
|
||||
return body.toLowerCase().includes('exceed')
|
||||
}
|
||||
|
||||
export function rejectionTitle(filename: string) {
|
||||
return `"${filename}" can't be used`
|
||||
}
|
||||
|
||||
export function sizeRejectionText(filename: string, bytes: number) {
|
||||
return `${filename} is ${formatMegabytes(bytes)} MB. Images can be up to 10 MB.`
|
||||
}
|
||||
+166
-46
@@ -11,6 +11,7 @@ import SaveStatus from '@/components/SaveStatus.vue'
|
||||
import PublishControl from '@/components/PublishControl.vue'
|
||||
import CharacterCounter from '@/components/CharacterCounter.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import ImageUploader from '@/components/ImageUploader.vue'
|
||||
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { useArticleDraft } from '@/composables/useArticleDraft'
|
||||
@@ -34,6 +35,10 @@ const showSkeleton = ref(false)
|
||||
const article = ref<Article | null>(null)
|
||||
const previewOn = ref(false)
|
||||
const detailsOpen = ref(false)
|
||||
const publishPending = ref<string | undefined>(undefined)
|
||||
const editorRef = ref<InstanceType<typeof ArticleEditor> | null>(null)
|
||||
const imageUploaderOpen = ref(false)
|
||||
const imageUploaderFile = ref<File | null>(null)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
@@ -72,15 +77,10 @@ const bodyLength = computed(() => draft.body.value.length)
|
||||
const overLimit = computed(() => bodyLength.value > BODY_MAX_LENGTH)
|
||||
|
||||
const saveEnabled = computed(
|
||||
() =>
|
||||
draft.state.value === 'dirty' &&
|
||||
!overLimit.value &&
|
||||
loadState.value === 'loaded',
|
||||
() => draft.state.value === 'dirty' && !overLimit.value && loadState.value === 'loaded',
|
||||
)
|
||||
|
||||
const publishDisabled = computed(
|
||||
() => overLimit.value || draft.state.value === 'offline',
|
||||
)
|
||||
const publishDisabled = computed(() => overLimit.value || draft.state.value === 'offline')
|
||||
|
||||
async function runSave() {
|
||||
const outcome = await draft.save()
|
||||
@@ -111,6 +111,130 @@ function goBack() {
|
||||
router.push('/article')
|
||||
}
|
||||
|
||||
async function setOnline(makeOnline: boolean) {
|
||||
if (!article.value) {
|
||||
return
|
||||
}
|
||||
publishPending.value = makeOnline ? 'Publishing…' : 'Unpublishing…'
|
||||
try {
|
||||
const updated = await request<Article>(
|
||||
`/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}`,
|
||||
{ method: 'PATCH' },
|
||||
)
|
||||
article.value = updated
|
||||
toast.add({
|
||||
id: `article-${makeOnline ? 'published' : 'unpublished'}-${articleId}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: makeOnline ? 'Article published' : 'Article unpublished',
|
||||
description: makeOnline
|
||||
? `"${updated.title}" is now online.`
|
||||
: `"${updated.title}" is now offline.`,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
loadState.value = 'notFound'
|
||||
return
|
||||
}
|
||||
toast.add({
|
||||
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${articleId}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: makeOnline ? "Couldn't publish" : "Couldn't unpublish",
|
||||
description: "The server didn't respond. Nothing changed.",
|
||||
duration: 0,
|
||||
actions: [{ label: 'Retry', onClick: () => setOnline(makeOnline) }],
|
||||
})
|
||||
} finally {
|
||||
publishPending.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function handlePublish() {
|
||||
setOnline(true)
|
||||
}
|
||||
|
||||
function handleUnpublish() {
|
||||
setOnline(false)
|
||||
}
|
||||
|
||||
function deleteDescription() {
|
||||
const count = article.value?.images.length ?? 0
|
||||
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.`
|
||||
}
|
||||
|
||||
async function performDelete() {
|
||||
try {
|
||||
await request(`/v1/article/${articleId}`, { method: 'DELETE' })
|
||||
toast.add({
|
||||
id: `article-deleted-${articleId}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Article deleted',
|
||||
description: `"${article.value?.title ?? ''}" and its images are gone.`,
|
||||
})
|
||||
router.push('/article')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
loadState.value = 'notFound'
|
||||
return
|
||||
}
|
||||
throw new Error("Couldn't delete the article, so it's still there. Try again in a moment.")
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!article.value) {
|
||||
return
|
||||
}
|
||||
await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Delete this article?',
|
||||
description: deleteDescription(),
|
||||
confirmLabel: 'Delete article',
|
||||
cancelLabel: 'Cancel',
|
||||
pendingLabel: 'Deleting…',
|
||||
tone: 'danger',
|
||||
action: performDelete,
|
||||
},
|
||||
})
|
||||
.open()
|
||||
}
|
||||
|
||||
function openImageUploader(file: File | null) {
|
||||
imageUploaderFile.value = file
|
||||
imageUploaderOpen.value = true
|
||||
}
|
||||
|
||||
function onRequestImage() {
|
||||
openImageUploader(null)
|
||||
}
|
||||
|
||||
function onEditorDrop(event: DragEvent) {
|
||||
const file = event.dataTransfer?.files?.[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
openImageUploader(file)
|
||||
}
|
||||
|
||||
function onImageUploaded(location: string) {
|
||||
editorRef.value?.insertImage(location, '')
|
||||
toast.add({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Image added',
|
||||
description: 'Save to keep it in the article.',
|
||||
})
|
||||
}
|
||||
|
||||
function onImageUploaderClosed() {
|
||||
imageUploaderFile.value = null
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault()
|
||||
@@ -154,21 +278,14 @@ onBeforeRouteLeave(async () => {
|
||||
<DashboardShell mobile-title="Edit article" full-bleed>
|
||||
<template v-if="loadState === 'loaded'" #breadcrumb>
|
||||
<UBreadcrumb
|
||||
:items="[
|
||||
{ label: 'Articles', to: '/article' },
|
||||
{ label: article?.title ?? '' },
|
||||
]"
|
||||
:items="[{ label: 'Articles', to: '/article' }, { label: article?.title ?? '' }]"
|
||||
color="neutral"
|
||||
class="hidden lg:block"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ErrorView v-if="loadState === 'notFound'" kind="article-not-found" />
|
||||
<ErrorView
|
||||
v-else-if="loadState === 'error'"
|
||||
kind="error"
|
||||
@retry="loadArticle"
|
||||
/>
|
||||
<ErrorView v-else-if="loadState === 'error'" kind="error" @retry="loadArticle" />
|
||||
|
||||
<div
|
||||
v-else-if="loadState === 'loading' && showSkeleton"
|
||||
@@ -187,10 +304,7 @@ onBeforeRouteLeave(async () => {
|
||||
aria-label="Back to Articles"
|
||||
@click="goBack"
|
||||
/>
|
||||
<USkeleton
|
||||
class="h-5 w-80 rounded-none"
|
||||
data-testid="skeleton-title"
|
||||
/>
|
||||
<USkeleton class="h-5 w-80 rounded-none" data-testid="skeleton-title" />
|
||||
<USkeleton class="h-5.5 w-15 rounded-none" />
|
||||
</div>
|
||||
<div
|
||||
@@ -215,10 +329,7 @@ onBeforeRouteLeave(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="loadState === 'loaded' && article"
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div v-else-if="loadState === 'loaded' && article" class="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
class="flex flex-shrink-0 flex-wrap items-center gap-3 border-b border-default bg-default px-6 py-3.5"
|
||||
>
|
||||
@@ -230,9 +341,7 @@ onBeforeRouteLeave(async () => {
|
||||
aria-label="Back to Articles"
|
||||
@click="goBack"
|
||||
/>
|
||||
<div
|
||||
class="flex min-w-50 flex-1 flex-wrap items-center gap-2.5"
|
||||
>
|
||||
<div class="flex min-w-50 flex-1 flex-wrap items-center gap-2.5">
|
||||
<h1
|
||||
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
|
||||
title="The title can't be changed after creation."
|
||||
@@ -254,7 +363,7 @@ onBeforeRouteLeave(async () => {
|
||||
root: 'items-center gap-2',
|
||||
container: 'h-auto',
|
||||
base: 'h-4.5 w-8 p-0.5',
|
||||
thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-3.5',
|
||||
thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-2.5',
|
||||
wrapper: 'ms-0',
|
||||
label: 'text-sm font-normal text-toned',
|
||||
}"
|
||||
@@ -274,15 +383,20 @@ onBeforeRouteLeave(async () => {
|
||||
class="h-8 gap-1.5 px-3"
|
||||
:online="article.is_online"
|
||||
:dirty="draft.state.value === 'dirty'"
|
||||
:pending="publishPending"
|
||||
:disabled="publishDisabled"
|
||||
@publish="handlePublish"
|
||||
@unpublish="handleUnpublish"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-ellipsis"
|
||||
color="neutral"
|
||||
icon="i-lucide-trash-2"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
class="size-8 justify-center text-toned"
|
||||
aria-label="More actions"
|
||||
/>
|
||||
class="h-8 gap-1.5 px-3"
|
||||
@click="handleDelete"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -301,18 +415,17 @@ onBeforeRouteLeave(async () => {
|
||||
<div
|
||||
class="hidden w-70 flex-shrink-0 border-l border-default bg-default p-5 lg:block"
|
||||
>
|
||||
<ArticleDetails
|
||||
:article="article"
|
||||
v-model:open="detailsOpen"
|
||||
/>
|
||||
<ArticleDetails :article="article" v-model:open="detailsOpen" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="min-h-0 flex-1" @dragover.prevent @drop.prevent="onEditorDrop">
|
||||
<ArticleEditor
|
||||
v-else
|
||||
class="min-h-0 flex-1"
|
||||
ref="editorRef"
|
||||
class="h-full"
|
||||
:model-value="draft.body.value"
|
||||
:article-id="articleId"
|
||||
@update:model-value="draft.edit"
|
||||
@request-image="onRequestImage"
|
||||
>
|
||||
<template #footer>
|
||||
<CharacterCounter
|
||||
@@ -323,17 +436,21 @@ onBeforeRouteLeave(async () => {
|
||||
</template>
|
||||
<template #aside>
|
||||
<div class="h-full border-l border-default bg-default p-5">
|
||||
<ArticleDetails
|
||||
:article="article"
|
||||
v-model:open="detailsOpen"
|
||||
/>
|
||||
<ArticleDetails :article="article" v-model:open="detailsOpen" />
|
||||
</div>
|
||||
</template>
|
||||
</ArticleEditor>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-shrink-0 gap-2 border-t border-default bg-default p-3 lg:hidden"
|
||||
>
|
||||
<ImageUploader
|
||||
v-model:open="imageUploaderOpen"
|
||||
:article-id="articleId"
|
||||
:initial-file="imageUploaderFile"
|
||||
@uploaded="onImageUploaded"
|
||||
@close="onImageUploaderClosed"
|
||||
/>
|
||||
|
||||
<div class="flex flex-shrink-0 gap-2 border-t border-default bg-default p-3 lg:hidden">
|
||||
<UButton
|
||||
icon="i-lucide-panel-right"
|
||||
color="neutral"
|
||||
@@ -357,7 +474,10 @@ onBeforeRouteLeave(async () => {
|
||||
hide-icon
|
||||
:online="article.is_online"
|
||||
:dirty="draft.state.value === 'dirty'"
|
||||
:pending="publishPending"
|
||||
:disabled="publishDisabled"
|
||||
@publish="handlePublish"
|
||||
@unpublish="handleUnpublish"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import RootComponent from '@/App.vue'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
|
||||
class FakeXMLHttpRequest {
|
||||
static instances: FakeXMLHttpRequest[] = []
|
||||
|
||||
method = ''
|
||||
url = ''
|
||||
status = 0
|
||||
responseText = ''
|
||||
withCredentials = false
|
||||
sentBody: FormData | null = null
|
||||
|
||||
private listeners: Record<string, Array<(event?: unknown) => void>> = {}
|
||||
private uploadListeners: Record<string, (event: { loaded: number; total: number }) => void> = {}
|
||||
|
||||
upload = {
|
||||
addEventListener: (
|
||||
type: string,
|
||||
cb: (event: { loaded: number; total: number }) => void,
|
||||
) => {
|
||||
this.uploadListeners[type] = cb
|
||||
},
|
||||
}
|
||||
|
||||
constructor() {
|
||||
FakeXMLHttpRequest.instances.push(this)
|
||||
}
|
||||
|
||||
open(method: string, url: string) {
|
||||
this.method = method
|
||||
this.url = url
|
||||
}
|
||||
|
||||
send(body: FormData) {
|
||||
this.sentBody = body
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.dispatch('abort')
|
||||
}
|
||||
|
||||
addEventListener(type: string, cb: (event?: unknown) => void) {
|
||||
this.listeners[type] = this.listeners[type] || []
|
||||
this.listeners[type].push(cb)
|
||||
}
|
||||
|
||||
dispatch(type: string, event?: unknown) {
|
||||
;(this.listeners[type] || []).forEach((cb) => cb(event))
|
||||
}
|
||||
|
||||
resolve(status: number, body: string) {
|
||||
this.status = status
|
||||
this.responseText = body
|
||||
this.dispatch('load')
|
||||
}
|
||||
}
|
||||
|
||||
function latestXHR() {
|
||||
return FakeXMLHttpRequest.instances[FakeXMLHttpRequest.instances.length - 1]
|
||||
}
|
||||
|
||||
function mib(value: number) {
|
||||
return Math.round(value * 1024 * 1024)
|
||||
}
|
||||
|
||||
function fileFixture(name: string, type: string, sizeMB: number) {
|
||||
const file = new File(['x'], name, { type })
|
||||
Object.defineProperty(file, 'size', { value: mib(sizeMB), configurable: true })
|
||||
return file
|
||||
}
|
||||
|
||||
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: (name: string) => headers[name] ?? null },
|
||||
json: async () => body,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function routeFor(url: string, init?: RequestInit) {
|
||||
const method = init?.method ?? 'GET'
|
||||
return `${method} ${url}`
|
||||
}
|
||||
|
||||
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
||||
return vi.fn((url: string, init?: RequestInit) => {
|
||||
const key = routeFor(url, init)
|
||||
const handler = handlers[key]
|
||||
if (!handler) {
|
||||
throw new Error(`Unexpected fetch call: ${key}`)
|
||||
}
|
||||
return handler()
|
||||
})
|
||||
}
|
||||
|
||||
function articleFixture(overrides: Partial<Article> = {}): Article {
|
||||
return {
|
||||
id: 12,
|
||||
title: 'How we price a bathroom renovation',
|
||||
body: '<p>a</p>',
|
||||
images: [],
|
||||
is_online: false,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: FIXED_NOW,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@nuxt/ui/composables', () => ({
|
||||
useOverlay: () => ({ create: vi.fn() }),
|
||||
useToast: () => ({ add: toastAdd }),
|
||||
}))
|
||||
|
||||
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
||||
|
||||
async function mountEditor(
|
||||
handlers: Record<string, () => Response | Promise<Response>>,
|
||||
path = '/article/12/edit',
|
||||
) {
|
||||
setActivePinia(createPinia())
|
||||
toastAdd = vi.fn()
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
|
||||
await router.push(path)
|
||||
const wrapper = mount(RootComponent, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [router, uiPlugin],
|
||||
},
|
||||
})
|
||||
currentWrapper = wrapper
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function saveButton(wrapper: Awaited<ReturnType<typeof mount>>) {
|
||||
return wrapper.find('[data-testid="save-button"]')
|
||||
}
|
||||
|
||||
function findButton(text: string) {
|
||||
return Array.from(document.body.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === text,
|
||||
)
|
||||
}
|
||||
|
||||
function toolbarImageButton(wrapper: Awaited<ReturnType<typeof mount>>) {
|
||||
return wrapper.findAll('button').find((button) => button.text() === 'Image')
|
||||
}
|
||||
|
||||
const RealXMLHttpRequest = global.XMLHttpRequest
|
||||
|
||||
beforeEach(() => {
|
||||
FakeXMLHttpRequest.instances = []
|
||||
global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
currentWrapper?.unmount()
|
||||
currentWrapper = null
|
||||
sessionStorage.clear()
|
||||
global.XMLHttpRequest = RealXMLHttpRequest
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe("Opening the uploader from the toolbar's image button shows the idle state", () => {
|
||||
it('opens ImageUploader idle and sends no request', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
||||
})
|
||||
|
||||
await toolbarImageButton(wrapper)?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('Add an image')
|
||||
expect(document.body.textContent).toContain('Drop an image here')
|
||||
expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
|
||||
expect(findButton('Choose file')).toBeTruthy()
|
||||
expect(findButton('Cancel')).toBeTruthy()
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dropping a valid file onto the editor opens the uploader already uploading it', () => {
|
||||
it('opens uploading, not idle, and sends the file', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
||||
})
|
||||
|
||||
const file = fileFixture('before.png', 'image/png', 3.9)
|
||||
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: { files: { 0: file, length: 1 } },
|
||||
})
|
||||
wrapper.findComponent(ArticleEditor).element.dispatchEvent(dropEvent)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('Add an image')
|
||||
expect(document.body.textContent).not.toContain('Drop an image here')
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
|
||||
expect(latestXHR().url).toBe('/v1/article/12/image')
|
||||
expect((latestXHR().sentBody?.get('file') as File).name).toBe('before.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A successful upload inserts the image at the cursor and marks the draft dirty', () => {
|
||||
it('inserts the image, marks the draft dirty and toasts', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
||||
})
|
||||
|
||||
await toolbarImageButton(wrapper)?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const fileInput = document.body.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||
const file = fileFixture('north-wall.jpg', 'image/jpeg', 3.9)
|
||||
Object.defineProperty(fileInput, 'files', {
|
||||
value: { 0: file, length: 1 },
|
||||
configurable: true,
|
||||
})
|
||||
fileInput.dispatchEvent(new Event('change'))
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
|
||||
latestXHR().resolve(
|
||||
201,
|
||||
JSON.stringify({ location: '/static/uploadedImages/north-wall.jpg' }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
const insertedBody = wrapper.findComponent(ArticleEditor).props('modelValue')
|
||||
expect(insertedBody).toContain('/static/uploadedImages/north-wall.jpg')
|
||||
expect(insertedBody).toContain('<img src="/static/uploadedImages/north-wall.jpg" alt="">')
|
||||
expect(wrapper.text()).toContain('Unsaved changes')
|
||||
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Image added',
|
||||
description: 'Save to keep it in the article.',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -205,6 +205,63 @@ async function typeExactBodyLength(page: Page, totalLength: number) {
|
||||
await page.keyboard.insertText(inner)
|
||||
}
|
||||
|
||||
function mib(value: number) {
|
||||
return Math.round(value * 1024 * 1024)
|
||||
}
|
||||
|
||||
async function openImageUploader(page: Page) {
|
||||
await page.getByRole('button', { name: 'Image', exact: true }).first().click()
|
||||
await page.getByRole('dialog').getByText('Add an image').waitFor()
|
||||
}
|
||||
|
||||
async function chooseImageFile(page: Page, filename: string, mimeType: string, size: number) {
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: filename,
|
||||
mimeType,
|
||||
buffer: Buffer.alloc(size),
|
||||
})
|
||||
}
|
||||
|
||||
async function mockUploadedImage(page: Page) {
|
||||
const isDark = await page.evaluate(
|
||||
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||
)
|
||||
const background = isDark ? 'rgb(26,26,26)' : 'rgb(244,244,245)'
|
||||
const border = isDark ? 'rgb(64,64,64)' : 'rgb(212,212,216)'
|
||||
await page.route('**/static/uploadedImages/**', async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="450"><rect width="100%" height="100%" fill="${background}" stroke="${border}" stroke-width="2" /><text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle" font-family="monospace" font-size="16" fill="${border}">${pathname}</text></svg>`
|
||||
await route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg })
|
||||
})
|
||||
}
|
||||
|
||||
async function freezeUploadProgress(page: Page, loaded: number, total: number) {
|
||||
await page.evaluate(
|
||||
({ loaded, total }) => {
|
||||
const proto = XMLHttpRequest.prototype as unknown as Record<string, unknown>
|
||||
const originalOpen = proto.open as (...args: unknown[]) => void
|
||||
proto.open = function (this: XMLHttpRequest, ...args: unknown[]) {
|
||||
;(this as unknown as { __imageUrl?: string }).__imageUrl = String(args[1])
|
||||
return originalOpen.apply(this, args)
|
||||
}
|
||||
const originalSend = proto.send as (...args: unknown[]) => void
|
||||
proto.send = function (this: XMLHttpRequest, ...args: unknown[]) {
|
||||
const url = (this as unknown as { __imageUrl?: string }).__imageUrl ?? ''
|
||||
if (url.includes('/image')) {
|
||||
setTimeout(() => {
|
||||
const event = new Event('progress')
|
||||
Object.assign(event, { loaded, total, lengthComputable: true })
|
||||
this.upload.dispatchEvent(event)
|
||||
}, 50)
|
||||
return
|
||||
}
|
||||
return originalSend.apply(this, args)
|
||||
}
|
||||
},
|
||||
{ loaded, total },
|
||||
)
|
||||
}
|
||||
|
||||
const newArticleTitle = 'Choosing tiles for a small bathroom'
|
||||
const newArticleBody = 'Small rooms show every grout line, so we start with the floor.'
|
||||
|
||||
@@ -846,6 +903,57 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
'GET /v1/article/9999': { status: 404 },
|
||||
},
|
||||
},
|
||||
'Upload: choose': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: editorApi(),
|
||||
steps: async (page) => {
|
||||
await markDirty(page)
|
||||
await openImageUploader(page)
|
||||
},
|
||||
},
|
||||
'Upload: in progress': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: editorApi(),
|
||||
steps: async (page) => {
|
||||
await markDirty(page)
|
||||
await openImageUploader(page)
|
||||
await freezeUploadProgress(page, mib(2.4), mib(3.9))
|
||||
await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9))
|
||||
await page.getByText('north-wall.jpg 62%').waitFor()
|
||||
},
|
||||
},
|
||||
'Upload: rejected': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: editorApi(),
|
||||
steps: async (page) => {
|
||||
await markDirty(page)
|
||||
await openImageUploader(page)
|
||||
await chooseImageFile(page, 'north-wall.tif', 'image/png', mib(14.2))
|
||||
await page.getByText(`"north-wall.tif" can't be used`).waitFor()
|
||||
},
|
||||
},
|
||||
'Upload: inserted': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: editorApi({
|
||||
'POST /v1/article/12/image': {
|
||||
status: 201,
|
||||
body: { location: '/static/uploadedImages/north-wall.jpg' },
|
||||
},
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await mockUploadedImage(page)
|
||||
await page.getByText('add a margin on materials').click()
|
||||
await page.keyboard.press('Home')
|
||||
await openImageUploader(page)
|
||||
await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9))
|
||||
await page.getByText('Save to keep it in the article.').first().waitFor()
|
||||
await page.waitForTimeout(650)
|
||||
},
|
||||
},
|
||||
},
|
||||
'new-article': {
|
||||
Default: {
|
||||
|
||||
@@ -15,6 +15,7 @@ export default defineConfig({
|
||||
proxy: {
|
||||
'/v1': 'http://localhost:8080',
|
||||
'/healthz': 'http://localhost:8080',
|
||||
'/static': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
Reference in new issue
Block a user