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
@@ -1,136 +1,191 @@
|
||||
Feature: Wiring the editor's image upload flow
|
||||
Feature: The editor's Upload tab uploads to the library
|
||||
|
||||
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
|
||||
I want the Upload tab of the "Add an image" dialog to check my file, spot duplicates,
|
||||
upload to my library and insert 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)
|
||||
Given this slice is SPA only for this file: the API from the media library slice is used
|
||||
unchanged
|
||||
And EditorView listens for ArticleEditor's "request-image" event and opens ImageUploader
|
||||
(a UModal titled "Add an image") with the picker tabs, whose Library tab is specified in
|
||||
features/editor_media_picker.feature
|
||||
And dropping a file onto the editor surface opens ImageUploader on the Upload tab with that
|
||||
file already provided, and no list request is sent until the Library tab is opened
|
||||
And this file supersedes these statements of the earlier version: the upload goes to
|
||||
POST /v1/media/ and no longer to POST /v1/article/{articleId}/image; the 201 body is the
|
||||
library item { id, filename, url, size, content_type, width, height, sha256, uploaded_by,
|
||||
created_at, used_in, duplicate }; there is no article id in any upload request and no 404
|
||||
for a deleted article on upload
|
||||
And ImageUploader validates a chosen or dropped file in the browser with validateImageFile
|
||||
from web/src/lib/imageUpload.ts, size first (10485760 bytes allowed, one byte more is too
|
||||
large), then MIME type and extension must both be allowed (png, jpeg, webp, svg), before
|
||||
hashing or sending anything
|
||||
And a file that passes validation is hashed with hashFile from web/src/lib/fileHash.ts (the
|
||||
lib shared with MediaPage), and GET /v1/media/?hash=<sha256 hex> is sent through useApi;
|
||||
when hashFile resolves null, or the lookup fails with 500 or a network error, the check
|
||||
is skipped and the flow continues as if nothing matched
|
||||
And a file with no match continues exactly as in features/editor_image_crop_resize.feature:
|
||||
a JPEG, PNG or WEBP opens the crop and resize step, an SVG is uploaded immediately
|
||||
And the upload is sent with XMLHttpRequest (not useApi) so progress is observable, as
|
||||
multipart/form-data with the file under the field name "file" to POST /v1/media/, with the
|
||||
jwt cookie
|
||||
And a 201 inserts the item's url at the cursor through insertImage(item.url, "", item.id),
|
||||
so the node carries data-image-id, and useArticleDraft moves to "dirty"
|
||||
And closing the dialog while an upload is in flight aborts the XMLHttpRequest
|
||||
And the tabs show only on the choose step (idle, the duplicate card and the Library tab);
|
||||
the crop, uploading, rejected and failed steps keep the earlier look without tabs
|
||||
And the footer on the Upload tab holds only the step's own actions ("Cancel" in idle);
|
||||
there is no "Insert image" button on it
|
||||
|
||||
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
|
||||
Rule: Opening
|
||||
|
||||
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: The toolbar's image button opens on Upload when the library is empty
|
||||
Given the user is on /article/12/edit with the editor focused
|
||||
And GET /v1/media/?limit=24 resolves 200 with no item
|
||||
When the user activates the toolbar's "Image" button
|
||||
Then ImageUploader opens titled "Add an image" with the tabs "Upload" (icon upload) and "Library" (icon images)
|
||||
And the Upload tab is selected and shows the image-plus icon, "Drop an image here, or choose a file" and "PNG, JPEG, WEBP, SVG · max 10 MB"
|
||||
And its footer reads "Cancel" only
|
||||
And no POST has been sent
|
||||
|
||||
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: Dropping a file opens Upload with the file
|
||||
Given the user is on /article/12/edit 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 on the Upload tab
|
||||
And GET /v1/media/?hash=<sha256 of the file> is sent, and no request to GET /v1/media/?limit=24 is sent
|
||||
And when the lookup resolves 200 with no item the crop and resize step shows for "before.png"
|
||||
And no POST /v1/media/ has been sent
|
||||
|
||||
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"
|
||||
Scenario: Dragging a file over the drop zone shows the dragging-over state
|
||||
Given ImageUploader is open on the Upload tab in the idle state
|
||||
When the user drags a file over the drop zone
|
||||
Then the drop zone reads "Drop to upload"
|
||||
|
||||
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. |
|
||||
Rule: Browser checks, nothing is sent
|
||||
|
||||
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 Outline: Choosing a file the browser can reject sends nothing
|
||||
Given ImageUploader is open in the idle state
|
||||
When the user chooses a file named "<filename>", content type "<content_type>", size <size>
|
||||
Then no hash lookup and no POST /v1/media/ is sent
|
||||
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"
|
||||
|
||||
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"
|
||||
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: 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: "Choose another file" from the rejected state returns to idle
|
||||
Given ImageUploader is in the rejected state for "plan.pdf"
|
||||
When the user activates "Choose another file"
|
||||
Then the idle state is shown again, with the tabs
|
||||
|
||||
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
|
||||
Rule: Already in your library
|
||||
|
||||
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
|
||||
Scenario: A file whose content exists shows the duplicate card and sends nothing
|
||||
Given ImageUploader is open in the idle state
|
||||
And GET /v1/media/?hash=<sha256 of north-wall.jpg> resolves 200 with the image "north-wall.jpg" (id 1, 4032 × 3024, uploaded 12 Sep 2026 by Marta Alves)
|
||||
When the user chooses a valid file named "north-wall.jpg" of 3.9 MB
|
||||
Then the Upload tab shows a card with an info border: a 72 × 54 thumbnail of the existing image, the title "Already in your library" and the text "north-wall.jpg matches an image uploaded on 12 Sep 2026 by Marta Alves."
|
||||
And the card has the buttons "Use existing" (primary) and "Upload anyway", stacked and full width below md
|
||||
And no POST /v1/media/ has been sent and the crop step has not opened
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
Scenario: Several matches name the oldest one
|
||||
Given the hash lookup resolves 200 with several images
|
||||
Then the card names the oldest match
|
||||
|
||||
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 match without a known uploader drops the "by" part
|
||||
Given the matched image's uploaded_by is empty
|
||||
Then the text reads "north-wall.jpg matches an image uploaded on 12 Sep 2026."
|
||||
|
||||
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: "Use existing" goes to the alt text step for the existing image
|
||||
Given the duplicate card is showing for image id 1
|
||||
When the user activates "Use existing"
|
||||
Then the Library tab is selected with "north-wall.jpg" selected, its panel visible with the empty "Alt text (optional)" field, and "Insert image" enabled
|
||||
And no POST /v1/media/ has been sent
|
||||
|
||||
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
|
||||
Scenario: "Upload anyway" continues to crop and upload
|
||||
Given the duplicate card is showing for "north-wall.jpg" (4032 × 3024)
|
||||
When the user activates "Upload anyway"
|
||||
Then the crop and resize step opens for that file
|
||||
And the hash lookup is not repeated
|
||||
And uploading from that step sends POST /v1/media/ and follows the normal states
|
||||
|
||||
Scenario: "Upload anyway" for an SVG uploads immediately
|
||||
Given the duplicate card is showing for "diagram.svg"
|
||||
When the user activates "Upload anyway"
|
||||
Then POST /v1/media/ is sent with that file
|
||||
|
||||
Scenario Outline: The duplicate check is skipped when it cannot run
|
||||
Given ImageUploader is open in the idle state
|
||||
When the user chooses a valid file and <condition>
|
||||
Then the flow continues to the crop step (or the upload for an SVG) with no card
|
||||
|
||||
Examples:
|
||||
| condition |
|
||||
| crypto.subtle is undefined (an insecure context) |
|
||||
| GET /v1/media/?hash= resolves with a 500 response |
|
||||
| GET /v1/media/?hash= never reaches the server |
|
||||
|
||||
Rule: Uploading
|
||||
|
||||
Scenario: A valid upload shows progress and its cancel action reads "Cancel upload"
|
||||
Given the crop step was confirmed (or an SVG chosen) for "north-wall.jpg", 3.9 MB
|
||||
Then POST /v1/media/ is sent with the 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 it 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 "north-wall.jpg"
|
||||
When the user activates "Cancel upload" (or otherwise closes the dialog)
|
||||
Then the in-flight XMLHttpRequest is aborted and the dialog closes
|
||||
And the editor's body and its dirty or clean state are unchanged
|
||||
|
||||
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 "north-wall.jpg" is uploading
|
||||
When POST /v1/media/ resolves 201 with the item { id: 13, url: "/static/uploadedImages/13.jpg", filename: "north-wall.jpg" }
|
||||
Then the uploading state briefly shows "Image added"
|
||||
And the image "/static/uploadedImages/13.jpg" is inserted at the cursor with alt "" and data-image-id 13
|
||||
And SaveStatus reads "Unsaved changes" and Save is 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 dialog closes 600 ms after the response
|
||||
|
||||
Scenario Outline: A server answer for a file that passed the browser checks
|
||||
Given ImageUploader is uploading "<file>" of <size>
|
||||
When POST /v1/media/ resolves <status>
|
||||
Then the <state> state shows the title "<title>"
|
||||
And its text reads "<text>"
|
||||
And its confirm action reads "<confirm>"
|
||||
And no image is inserted and the editor's body is unchanged
|
||||
|
||||
Examples:
|
||||
| file | size | status | state | title | text | confirm |
|
||||
| notes.pdf | 210 KB | 415 | rejected | "notes.pdf" can't be used | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. | Choose another file |
|
||||
| north-wall.tif | 14.2 MB | 413 | rejected | "north-wall.tif" can't be used | north-wall.tif is 14.2 MB. Images can be up to 10 MB. | Choose another file |
|
||||
| broken.png | 3.0 MB | 400 | rejected | "broken.png" can't be used | Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. | Choose another file |
|
||||
| north-wall.jpg | 3.9 MB | 500 | failed | Upload failed | The server didn't accept the file. Your article hasn't changed. | Try again |
|
||||
| north-wall.jpg | 3.9 MB | 429 | failed | Upload failed | The server didn't accept the file. Your article hasn't changed. | Try again |
|
||||
| north-wall.jpg | 3.9 MB | none (network) | failed | Upload failed | The server didn't accept the file. Your article hasn't changed. | Try again |
|
||||
|
||||
Scenario: "Try again" re-attempts the upload with the same file
|
||||
Given ImageUploader is in the failed state for "north-wall.jpg" after a 500
|
||||
When the user activates "Try again"
|
||||
Then POST /v1/media/ is sent again with that same file and no new hash lookup, and the uploading state shows
|
||||
|
||||
Rule: Sessions
|
||||
|
||||
Scenario Outline: Session errors are global
|
||||
When <call> resolves <status>
|
||||
Then the user is sent where useApi sends a <status> from /article/12/edit, and no local copy shows
|
||||
|
||||
Examples:
|
||||
| call | status |
|
||||
| POST /v1/media/ (XHR) | 401 |
|
||||
| POST /v1/media/ (XHR) | 403 |
|
||||
| GET /v1/media/?hash= | 401 |
|
||||
Reference in new issue
Block a user