feat: editor image removal QOLs

This commit is contained in:
fsylvain committed 2026-09-29 02:44:31 +02:00
1 parent 0fc00ceeff
commit b29d0ed045
23 files changed
+1398 -151

No files matched your search

+34 -2
View File
@@ -17,13 +17,15 @@ type Handler struct {
CreateUseCase *useCases.CreateImageUseCase
GetUseCase *useCases.GetArticleUseCase
UpdateUseCase *useCases.UpdateArticleUseCase
DeleteUseCase *useCases.DeleteImageUseCase
}
func NewHandler(create *useCases.CreateImageUseCase, get *useCases.GetArticleUseCase, update *useCases.UpdateArticleUseCase) *Handler {
func NewHandler(create *useCases.CreateImageUseCase, get *useCases.GetArticleUseCase, update *useCases.UpdateArticleUseCase, deleteUseCase *useCases.DeleteImageUseCase) *Handler {
return &Handler{
CreateUseCase: create,
GetUseCase: get,
UpdateUseCase: update,
DeleteUseCase: deleteUseCase,
}
}
@@ -78,7 +80,7 @@ func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) {
return
}
responseJson, err := json.Marshal(map[string]any{"location": newImage.Path})
responseJson, err := json.Marshal(map[string]any{"id": newImage.ID, "location": newImage.Path})
if err != nil {
writeInternalServerError(w)
return
@@ -87,3 +89,33 @@ func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(responseJson)
}
func (h *Handler) DeleteImage(w http.ResponseWriter, r *http.Request) {
articleId, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
imageId, err := strconv.ParseUint(chi.URLParam(r, "imageId"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
if _, err := h.GetUseCase.GetArticle(uint32(articleId)); err != nil {
writeArticleError(w, err)
return
}
if err := h.DeleteUseCase.DeleteImage(uint32(imageId)); err != nil {
if errors.Is(err, domainImage.ErrImageNotFound) {
http.Error(w, apperr.ImageNotFoundErrorMessage, http.StatusNotFound)
return
}
writeInternalServerError(w)
return
}
w.WriteHeader(http.StatusNoContent)
}
+1
View File
@@ -4,6 +4,7 @@ const LogsErrorMessage = "Access to the requested resource is forbidden due to i
const BodyErrorMessage = "The request cannot be processed due to a mismatch in the format of the body."
const IdUint32ErrorMessage = "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
const ArticleNotFoundErrorMessage = "Article not found"
const ImageNotFoundErrorMessage = "Image not found"
const ArticleTitleErrorMessage = "Article title must be between 3 and 200 characters."
const RegistrationClosedErrorMessage = "You are not allowed to create a user. Log in or reset database."
const RegistrationFailedErrorMessage = "Registration failed. Please try again later."
+2 -1
View File
@@ -33,7 +33,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
imageHandler := image.NewHandler(container.CreateImageUseCase, container.GetArticleUseCase, container.UpdateArticleUseCase)
imageHandler := image.NewHandler(container.CreateImageUseCase, container.GetArticleUseCase, container.UpdateArticleUseCase, container.DeleteImageUseCase)
r.Group(func(r chi.Router) {
r.Get("/", articleHandler.ListArticles)
@@ -49,6 +49,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
r.Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
r.Post("/{id}/image", imageHandler.PostImage)
r.Delete("/{id}/image/{imageId}", imageHandler.DeleteImage)
})
return r
}
+148
View File
@@ -0,0 +1,148 @@
Feature: Removing an inline image from the editor
As a verified RenewCMS owner writing an article
I want to remove an image I've already inserted into the article body,
either through an explicit delete button or by pressing Backspace/Delete
on the selected image, directly from the editor
So that a mistaken or unwanted image doesn't have to stay in the article
forever, and removing it behaves like every other destructive action in
RenewCMS: it asks for confirmation first, however the user starts it
Background:
Given docs/design/ has no board or state for removing an inline image;
this feature was added at the user's explicit request ahead of design
coverage, reusing the editor's existing visual language rather than
inventing new components: the trash icon and error color already used
by the article "Delete" button, and the ConfirmDialog component
already used by "Delete this article?" (docs/design/screens/editor.md's
"Delete: confirm" state)
And this slice changes ArticleEditor's exposed insertImage(src, alt) to
insertImage(src, alt, imageId), stamping the inserted <img> node with
the image's numeric id as a data-image-id attribute, persisted in the
saved article body HTML; EditorView passes the id it now receives from
a successful upload (features/remove_article_image.feature's change to
POST .../image's 201 body) through to insertImage
And an image already present in an article's body from before this slice
has no data-image-id attribute and so shows no delete affordance and
is not intercepted by Backspace/Delete, until it is removed and
re-added
And selecting an image node that does carry a data-image-id attribute
shows a small delete button anchored to the image's top-right corner,
icon i-lucide-trash-2, color error, matching the article Delete
button's icon and color; deselecting the image (clicking elsewhere)
hides it
And there are two equivalent triggers for the same removal flow:
activating that delete button, and pressing Backspace or Delete while
the image node is selected (a Tiptap NodeSelection on the image).
Tiptap's native node deletion on Backspace/Delete is intercepted
before it reaches the document (e.g. via a keyboard-shortcut/keydown
handler registered on the Image node or at the editor level, checked
first while an image node is selected) so the key press never removes
the node directly; instead it triggers exactly the same flow as the
button
And either trigger emits ArticleEditor's new "request-remove-image"
event with the node's numeric image id; ArticleEditor does not call
the API or show any dialog itself, mirroring how "request-image"
already only opens ImageUploader from EditorView
And EditorView reacts to "request-remove-image" by opening the existing
ConfirmDialog component with title "Remove this image?", description
"This image will be removed from the article. This can't be undone.",
confirmLabel "Remove image", cancelLabel "Cancel", pendingLabel
"Removing…", tone "danger" — the same component and tone already used
for "Delete this article?"
And ConfirmDialog's action calls DELETE /v1/article/{articleId}/image/
{imageId} through useApi (not the raw XMLHttpRequest used for upload,
since there is no progress to observe)
And on success (204) the action removes the image node from
ArticleEditor's content via a new exposed removeImage(imageId) method,
which moves useArticleDraft's state to "dirty" the same way any other
body edit does; ConfirmDialog then closes and a toast appears: color
success, icon i-lucide-circle-check, title "Image removed",
description "Save to keep this change."
And on any failure (404 for either the article or the image, 500, or a
network failure) the action throws, so ConfirmDialog shows its own
inline error "Couldn't remove the image, so it's still there. Try
again in a moment." and stays open; the image node is not removed and
the draft's dirty state is unchanged
And a 401 is handled entirely by useApi's global handling
(features/api_client_global_error_handling.feature) and is out of
scope for new scenarios here
And once the confirm dialog is open, its cancel/confirm/failure
behaviour (covered below) is identical regardless of which trigger
opened it, so those scenarios are written once, against the button
trigger, and are not duplicated per trigger
Scenario: Selecting an inserted image reveals its delete button
Given the user is on /article/12/edit with an image in the body carrying
data-image-id "10"
When the user clicks that image
Then a delete button (trash icon, error color) appears at the image's
top-right corner
When the user clicks elsewhere in the editor
Then the delete button is no longer shown
Scenario Outline: Either trigger opens the confirm dialog and sends no request yet, without letting Tiptap delete the node itself
Given the image with data-image-id "10" is selected on /article/12/edit
When the user <triggers-removal>
Then a dialog titled "Remove this image?" opens, with description "This
image will be removed from the article. This can't be undone.",
cancel "Cancel" and confirm "Remove image"
And no request has been sent to DELETE /v1/article/12/image/10
And the image node is still present in the editor's content (Tiptap's
native node deletion did not run)
Examples:
| triggers-removal |
| activates the image's delete button |
| presses Backspace with the image node selected |
| presses Delete with the image node selected |
Scenario: Cancelling the confirm dialog changes nothing
Given the confirm dialog is open for image id 10 on /article/12/edit
When the user activates "Cancel"
Then the dialog closes
And no request has been sent to DELETE /v1/article/12/image/10
And the image is still present in the editor's content
And the draft's dirty state is unchanged
Scenario: Confirming removes the image, marks the draft dirty, and shows a success toast
Given the confirm dialog is open for image id 10 on /article/12/edit,
the draft in the Saved state
When the user activates "Remove image"
Then DELETE /v1/article/12/image/10 is sent
When that request resolves 204
Then the dialog closes
And the image is removed from the editor's content
And SaveStatus reads "Unsaved changes" and the Save button becomes
enabled
And a toast appears: color success, icon i-lucide-circle-check, title
"Image removed", description "Save to keep this change."
Scenario Outline: A failed removal keeps the image and shows the dialog's inline error
Given the confirm dialog is open for image id 10 on /article/12/edit
When the user activates "Remove image"
And DELETE /v1/article/12/image/10 <failure>
Then the dialog stays open, showing the inline error "Couldn't remove
the image, so it's still there. Try again in a moment."
And the image is still present in the editor's content
And the draft's dirty state is unchanged
Examples:
| failure |
| resolves with a 404 response |
| resolves with a 500 response |
| never reaches the server |
Scenario: Confirming a removal started by Backspace/Delete behaves exactly like confirming one started by the button
Given the confirm dialog is open for image id 10 on /article/12/edit,
opened by pressing Backspace with the image node selected, the draft
in the Saved state
When the user activates "Remove image"
Then DELETE /v1/article/12/image/10 is sent
When that request resolves 204
Then the dialog closes
And the image is removed from the editor's content
And SaveStatus reads "Unsaved changes" and the Save button becomes
enabled
And a toast appears: color success, icon i-lucide-circle-check, title
"Image removed", description "Save to keep this change."
+88
View File
@@ -0,0 +1,88 @@
# QA: Removing an inline image from the editor
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount EditorPage inside DashboardShell at `/article/12/edit` with the
editor_core.qa.md fixture article (id 12, "How we price a bathroom
renovation", is_online false), GET already resolved 200, and a body
containing `<img src="/static/uploadedImages/before.png" data-image-id="10"
alt="">`. Mock `useApi`'s `request` for `DELETE /v1/article/12/image/10`
(fetch-based, not XHR — reuse whatever mock
`api_client_global_error_handling.qa.md` already sets up for `request`).
1. **Selecting the image reveals the delete button** — click the image
node; assert a delete button (trash icon, error color) renders anchored
to it; click elsewhere in the editor; assert the button is gone.
2. **Activating the delete button opens the confirm dialog** — select the
image, activate its delete button; assert a dialog titled "Remove this
image?", description "This image will be removed from the article. This
can't be undone.", cancel "Cancel", confirm "Remove image"; assert no
request was sent yet; assert the image node is still present (Tiptap's
native deletion did not run).
3. **Backspace on the selected image opens the same confirm dialog instead
of deleting the node** — select the image node (NodeSelection), fire a
`keydown` for `Backspace`; assert the same dialog as step 2 opens;
assert no request was sent yet; assert the image node is still present
in the editor's document (i.e. the keydown was intercepted before
Tiptap's default node-deletion ran, not just undone afterwards).
4. **Delete on the selected image opens the same confirm dialog instead of
deleting the node** — same as step 3 but firing `keydown` for `Delete`.
5. **Cancelling changes nothing** — from the open dialog (opened via the
button), click "Cancel"; assert the dialog closes, no request was sent,
the image node is still in the editor's content, and SaveStatus/dirty
state is unchanged.
6. **Confirming removes the image, marks dirty, and toasts (button
trigger)** — from the open dialog (Saved state), click "Remove image";
assert a DELETE request to `/v1/article/12/image/10` was made; resolve
it 204; assert the dialog closes, the image node is gone from the
editor's content, SaveStatus reads "Unsaved changes" and Save is
enabled, and a toast appears with color success, icon
`i-lucide-circle-check`, title "Image removed", description "Save to
keep this change."
7. **Confirming removes the image the same way when opened via
Backspace/Delete** — repeat step 6's assertions, but open the dialog via
the Backspace keydown from step 3 instead of the button; assert the
outcome (request, node removal, dirty state, toast) is identical.
8. **A failed removal keeps the image and shows the inline error,
Examples-driven** — for a 404 response, a 500 response, and a rejected/
network-failure request: from the open dialog (button trigger), click
"Remove image"; resolve/reject accordingly; assert the dialog stays open
showing the inline error "Couldn't remove the image, so it's still
there. Try again in a moment."; assert the image node is still present
and the draft's dirty state is unchanged.
## Visual
No `docs/design/` board or state exists for this trigger (see the feature's
Background). Per the user's explicit direction this slice ships the SPA
behaviour anyway, but `task visual:check -- editor` has nothing to compare
it against, so no visual capture is added for it — this is an intentional
gap, `skip: no-design-coverage` (distinct from `skip: clock-sensitive`), not
a QA oversight. If a board for this state is added to `docs/design/` later,
it needs its own follow-up slice to add the visual check.
- [ ] Keyboard/focus: once a delete button is shown, it is reachable by Tab
and activates on Enter/Space; pressing Backspace/Delete with the
image node selected but focus elsewhere in the page does not trigger
removal (only an actual Tiptap NodeSelection on the image should);
ConfirmDialog's existing focus trap and cancel-button-default-focus
behaviour (already covered by the article-delete flow) applies
unchanged regardless of which trigger opened it.
## Manual verification
- **Real DELETE round trip against a running server** — Vitest only mocks
`useApi`'s request; confirm a real removal (204/404/500) against
`task dev` with a live SQLite file and the real
`DELETE /v1/article/{id}/image/{imageId}` handler, since
`features/remove_article_image.feature`'s httptest suite covers the
handler in isolation but never through this SPA flow.
- **Real keyboard behaviour** — confirm in a real browser that selecting an
image (click) and pressing Backspace/Delete opens the confirm dialog
rather than deleting the node, including with an IME or on a platform
where Backspace/Delete semantics differ, since jsdom's `keydown`
simulation doesn't exercise Tiptap's real ProseMirror keymap precedence.
- **Design review of the delete-button/ConfirmDialog approach** — since
there is no board for this state, once a board is added, re-verify this
implementation still matches it (or update the implementation to match);
until then this is a best-effort, design-team-unreviewed UI.
+99
View File
@@ -0,0 +1,99 @@
Feature: Removing an image from an article
As an authenticated RenewCMS user managing the CMS
I want to delete an already-uploaded image by its id
So that a removed image's file and row are actually gone, and a delete on
an unknown article or an unknown image answers 404 the same way every
other article write does
Background:
Given internal/infrastructure/useCases.DeleteImageUseCase.DeleteImage(imageId)
already exists and calls image.Repository.Delete(imageId), which returns
image.ErrImageNotFound for an unknown id and, on the real ImageRepository,
also removes the uploaded file from UPLOAD_DIR, per
features/delete_article_cascades_images.feature
And this slice changes POST /v1/article/{id}/image's success response
(api/handlers/image.Handler.PostImage, features/upload_article_image.feature)
to also return the created image's numeric id, so the 201 body becomes
{"id": ..., "location": ...}; this lets a client address the image later
through this slice's DELETE route
And this slice mounts DELETE /v1/article/{articleId}/image/{imageId} in
api/router.go, in the same jwtauth Verifier + Authenticator group as
POST .../image
And this slice adds a DeleteImage method to api/handlers/image.Handler,
grows NewHandler's signature to also take DeleteImageUseCase, and wires
it in api/router.go
And DeleteImage parses articleId and imageId from the URL, answering 400
with apperr.IdUint32ErrorMessage if either isn't a valid uint32
And DeleteImage first calls GetArticleUseCase.GetArticle(articleId);
errors.Is(err, article.ErrArticleNotFound) answers 404 with
apperr.ArticleNotFoundErrorMessage; this only confirms the article id
in the URL names a real article, and does not verify that the image id
belongs to that article's Images slice (a shallow existence check on
both ids, not an ownership check)
And DeleteImage then calls DeleteImageUseCase.DeleteImage(imageId);
errors.Is(err, image.ErrImageNotFound) answers 404 with the new
apperr.ImageNotFoundErrorMessage = "Image not found"; any other error
falls back to the existing generic 500
And on success DeleteImage answers 204 with an empty body, the same shape
as DELETE /v1/article/{id}
Scenario: Removing an image from an article it belongs to succeeds
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update" whose Images slice holds image id 10
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/1/image/10 with that cookie
Then the response status is 204
And the response body is empty
And the image repository no longer contains an image with id 10
Scenario: Removing an image from an unknown article answers 404 and never touches the image repository
Given a valid jwt cookie for a user with id 1
And the article repository has no article with id 9999
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/9999/image/10 with that cookie
Then the response status is 404
And the response body is the text "Article not found"
And the image repository's Delete is never called
And the image repository still contains the image with id 10
Scenario: Removing an unknown image from an existing article answers 404
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update"
And the image repository has no image with id 9999
When the client sends DELETE /v1/article/1/image/9999 with that cookie
Then the response status is 404
And the response body is the text "Image not found"
Scenario: Removing an image id that belongs to a different article still succeeds, because the check is shallow
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update" whose Images slice is empty
And the article repository contains a second article with id 2 whose
Images slice holds image id 10
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/1/image/10 with that cookie
Then the response status is 204
And the image repository no longer contains an image with id 10
Scenario: Removing an image still requires authentication
Given no jwt cookie is sent
And the article repository contains an article with id 1 titled
"Backyard Renovation Update" whose Images slice holds image id 10
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/1/image/10 without a cookie
Then the response status is 401
And the image repository still contains the image with id 10
Scenario Outline: A malformed id in the URL answers 400 before either repository is touched
Given a valid jwt cookie for a user with id 1
When the client sends DELETE /v1/article/<article_id>/image/<image_id> with that cookie
Then the response status is 400
And the response body is the text "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
Examples:
| article_id | image_id |
| abc | 10 |
| 1 | abc |
+56
View File
@@ -0,0 +1,56 @@
# QA procedure: remove_article_image
Package: `test` (black-box, extending `article_test.go` / `image_test.go`).
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Reuse `mockArticleRepo` and `mockImageRepo` as-is.
3. Wire `useCases.NewDeleteImageUseCase(imageRepo)` (already used by
`delete_article_cascades_images.qa.md`'s suite) alongside the existing
use cases.
4. Build `image.NewHandler(...)` with its grown constructor including the
delete use case, matching whatever parameter order the coder settles on.
5. Build a test `jwtauth.New("HS256", []byte("test-secret"), nil)`.
6. Mount `DELETE /{id}/image/{imageId}` alongside the existing article
routes under the authenticated group, mirroring `api/router.go` after
this slice.
7. Mint a valid jwt cookie for user id 1 as in `upload_article_image.qa.md`.
## HTTP checks
8. Seed `articleRepo.articles[1]` with `Images: []*image.Image{{ID: 10}}`
and `imageRepo.images[10] = image.Image{ID: 10}`. Send
`DELETE /v1/article/1/image/10` with the valid cookie. Assert status
204, empty body; assert `imageRepo.images` no longer has key 10.
9. Empty `articleRepo`; seed `imageRepo.images[10]`. Send
`DELETE /v1/article/9999/image/10`. Assert 404, body
`"Article not found\n"`; assert `imageRepo.images` still has key 10
(Delete never called — track via a call-count field on the fake, the
same way the existing fakes already track `Create`).
10. Seed `articleRepo.articles[1]`; empty `imageRepo`. Send
`DELETE /v1/article/1/image/9999`. Assert 404, body
`"Image not found\n"`.
11. Seed `articleRepo.articles[1]` (empty Images) and `articleRepo.articles[2]`
with `Images: []*image.Image{{ID: 10}}`; seed `imageRepo.images[10]`.
Send `DELETE /v1/article/1/image/10`. Assert 204; assert
`imageRepo.images` no longer has key 10 — asserts the shallow-check
contract from the feature's fourth scenario.
12. Seed as in step 8. Send `DELETE /v1/article/1/image/10` with **no**
cookie. Assert 401; assert `imageRepo.images` still has key 10.
13. Examples-driven: send `DELETE /v1/article/abc/image/10` and
`DELETE /v1/article/1/image/abc` with the valid cookie. Assert 400,
body `"The server expects the ID to be in the format of an unsigned
32-bit integer (uint32).\n"` for each.
## Also touched by this slice: upload_article_image's 201 body
14. Extend `upload_article_image.qa.md`'s existing 201-body assertion to
also decode and check the `id` field equals the newly created image's
`ID` in `imageRepo.images`.
## Manual verification (not exercised by the Go test)
- Real DELETE against a real SQLite file and real UPLOAD_DIR: confirm the
file is actually removed from disk and the `article_images` join row is
gone, the same way `upload_article_image.qa.md`'s manual item covers the
POST side.
- SPA-visible behaviour: covered separately by
`features/editor_image_removal.qa.md`.
- No SMTP involvement.
+8 -3
View File
@@ -37,9 +37,12 @@ Feature: Uploading an image to an article
back to a generic 500
And PostImage reads the uploaded file from the multipart form field
"file"
And on success PostImage answers 201 with a JSON body {"location": ...}
holding the created image's public path, e.g.
"/static/uploadedImages/<stored-filename>"
And on success PostImage answers 201 with a JSON body
{"id": ..., "location": ...} holding the created image's numeric id
and public path, e.g. "/static/uploadedImages/<stored-filename>"
And this slice (features/remove_article_image.feature) adds the "id"
field to that body; the id lets a client later address the image
through DELETE /v1/article/{id}/image/{imageId}
Scenario: Uploading an allowed image type to an existing article succeeds
Given a valid jwt cookie for a user with id 1
@@ -51,6 +54,8 @@ Feature: Uploading an image to an article
Then the response status is 201
And the response body is JSON with a "location" field whose value is
"/static/uploadedImages/before.png"
And the response body's "id" field equals the new image's id in the
image repository
And the image repository contains a new image whose Path is
"/static/uploadedImages/before.png"
And the article repository's article with id 1 has that new image's id
+196
View File
@@ -0,0 +1,196 @@
package test
import (
"RenewCMS/api/handlers/image"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newRemoveArticleImageRouter(articleRepo *mockArticleRepo, imageRepo *mockImageRepo, token *jwtauth.JWTAuth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
imageHandler := image.NewHandler(
useCases.NewCreateImageUseCase(imageRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewDeleteImageUseCase(imageRepo),
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Delete("/{id}/image/{imageId}", imageHandler.DeleteImage)
})
})
})
})
return r
}
func setRemoveImageJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth) {
t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
func TestRemoveArticleImageHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("Removing an image from an article it belongs to succeeds", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update", Images: []*domainImage.Image{{ID: 10}}}
imageRepo := newMockImageRepo()
imageRepo.images[10] = domainImage.Image{ID: 10, Path: "/static/uploadedImages/before.png"}
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1/image/10", nil)
setRemoveImageJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d, body %q", rec.Code, rec.Body.String())
}
if rec.Body.Len() != 0 {
t.Errorf("expected empty body, got %q", rec.Body.String())
}
if _, ok := imageRepo.images[10]; ok {
t.Errorf("expected image 10 to have been removed")
}
})
t.Run("Removing an image from an unknown article answers 404 and never touches the image repository", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
imageRepo.images[10] = domainImage.Image{ID: 10, Path: "/static/uploadedImages/before.png"}
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/9999/image/10", nil)
setRemoveImageJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if len(imageRepo.deleteCalls) != 0 {
t.Errorf("expected Delete to never be called, got %d calls", len(imageRepo.deleteCalls))
}
if _, ok := imageRepo.images[10]; !ok {
t.Errorf("expected image 10 to still exist")
}
})
t.Run("Removing an unknown image from an existing article answers 404", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
imageRepo := newMockImageRepo()
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1/image/9999", nil)
setRemoveImageJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
if rec.Body.String() != apperr.ImageNotFoundErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
t.Run("Removing an image id that belongs to a different article still succeeds, because the check is shallow", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
articleRepo.articles[2] = domainArticle.Article{ID: 2, Images: []*domainImage.Image{{ID: 10}}}
imageRepo := newMockImageRepo()
imageRepo.images[10] = domainImage.Image{ID: 10, Path: "/static/uploadedImages/before.png"}
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1/image/10", nil)
setRemoveImageJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d, body %q", rec.Code, rec.Body.String())
}
if _, ok := imageRepo.images[10]; ok {
t.Errorf("expected image 10 to have been removed")
}
})
t.Run("Removing an image still requires authentication", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update", Images: []*domainImage.Image{{ID: 10}}}
imageRepo := newMockImageRepo()
imageRepo.images[10] = domainImage.Image{ID: 10, Path: "/static/uploadedImages/before.png"}
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1/image/10", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d", rec.Code)
}
if _, ok := imageRepo.images[10]; !ok {
t.Errorf("expected image 10 to still exist")
}
})
t.Run("A malformed id in the URL answers 400 before either repository is touched", func(t *testing.T) {
testCases := []struct {
name string
articleId string
imageId string
}{
{"malformed article id", "abc", "10"},
{"malformed image id", "1", "abc"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/"+tc.articleId+"/image/"+tc.imageId, nil)
setRemoveImageJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
if rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
}
})
}
+6 -1
View File
@@ -29,6 +29,7 @@ func newUploadArticleImageRouter(articleRepo *mockArticleRepo, imageRepo *mockIm
useCases.NewCreateImageUseCase(imageRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewDeleteImageUseCase(imageRepo),
)
r := chi.NewRouter()
@@ -98,7 +99,7 @@ func TestUploadArticleImageHttp(t *testing.T) {
t.Fatalf("expected status 201, got %d, body %q", rec.Code, rec.Body.String())
}
var got map[string]string
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
@@ -120,6 +121,10 @@ func TestUploadArticleImageHttp(t *testing.T) {
if !found {
t.Fatalf("expected the image repository to contain the new image")
}
gotId, ok := got["id"].(float64)
if !ok || uint32(gotId) != storedImageID {
t.Errorf("expected id %v, got %v", storedImageID, got["id"])
}
updatedArticle := articleRepo.articles[1]
imageAttached := false
@@ -35,6 +35,7 @@ func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mo
useCases.NewCreateImageUseCase(imageRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewDeleteImageUseCase(imageRepo),
)
r := chi.NewRouter()
+3
View File
@@ -9,6 +9,9 @@
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3",
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@tiptap/extension-image": "3.31.3",
"@tiptap/pm": "3.31.3",
"@tiptap/vue-3": "3.31.3",
"@vueuse/core": "14.4.0",
"pinia": "^3.0.4",
"shiki": "^4.4.3",
+3
View File
@@ -20,6 +20,9 @@
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3",
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@tiptap/extension-image": "3.31.3",
"@tiptap/pm": "3.31.3",
"@tiptap/vue-3": "3.31.3",
"@vueuse/core": "14.4.0",
"pinia": "^3.0.4",
"shiki": "^4.4.3",
+25 -3
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/core'
import { ArticleIndent } from '@/lib/articleIndent'
import { ArticleImage, findArticleImagePos } from '@/lib/articleImage'
const props = withDefaults(
defineProps<{
@@ -18,10 +19,16 @@ const props = withDefaults(
const emit = defineEmits<{
'update:modelValue': [value: string]
'request-image': []
'request-remove-image': [imageId: number]
}>()
const editorInstance = ref<Editor | null>(null)
const extensions = [ArticleIndent]
const extensions = [
ArticleIndent,
ArticleImage.configure({
onRequestRemove: (imageId: number) => emit('request-remove-image', imageId),
}),
]
function captureEditor(editor: Editor) {
editorInstance.value = editor
@@ -198,8 +205,22 @@ defineExpose({
focus() {
editorInstance.value?.commands.focus()
},
insertImage(src: string, alt: string) {
editorInstance.value?.commands.setImage({ src, alt })
insertImage(src: string, alt: string, imageId: number) {
editorInstance.value?.commands.setArticleImage({ src, alt, imageId })
},
removeImage(imageId: number) {
const editor = editorInstance.value
if (!editor) {
return
}
const pos = findArticleImagePos(editor, imageId)
if (pos === null) {
return
}
editor
.chain()
.deleteRange({ from: pos, to: pos + 1 })
.run()
},
})
</script>
@@ -209,6 +230,7 @@ defineExpose({
v-slot="{ editor }"
:model-value="props.modelValue"
:extensions="extensions"
:image="false"
content-type="html"
:editable="!props.disabled"
:ui="{ base: 'article-content' }"
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
import { articleImageId } from '@/lib/articleImage'
const props = defineProps(nodeViewProps)
const imageId = computed(() => articleImageId(props.node))
const showDeleteButton = computed(() => props.selected && imageId.value !== null)
interface ImageNodeAttrs {
src?: string
alt?: string
title?: string
width?: number | string
height?: number | string
}
const imageAttrs = computed(() => {
const { src, alt, title, width, height } = props.node.attrs as ImageNodeAttrs
return { src, alt, title, width, height, 'data-image-id': props.node.attrs['data-image-id'] }
})
function onDeleteClick() {
if (imageId.value === null) {
return
}
props.extension.options.onRequestRemove(imageId.value)
}
</script>
<template>
<NodeViewWrapper
as="div"
class="relative block w-fit"
:class="{ 'outline-2 outline-primary rounded-md': selected }"
data-testid="article-editor-image"
@dragstart.prevent
>
<img v-bind="imageAttrs" draggable="false" class="block max-w-full" />
<UButton
v-if="showDeleteButton"
data-testid="remove-image-button"
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
class="absolute top-1 right-1 bg-default"
aria-label="Remove image"
@mousedown.prevent
@click="onDeleteClick"
/>
</NodeViewWrapper>
</template>
+2 -2
View File
@@ -41,7 +41,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
uploaded: [location: string]
uploaded: [location: string, imageId: number]
close: []
}>()
@@ -167,7 +167,7 @@ async function runUpload(file: File) {
const outcome = await start(props.articleId, file)
if (outcome.kind === 'success') {
succeeded.value = true
emit('uploaded', outcome.location)
emit('uploaded', outcome.location, outcome.id)
closeTimer = setTimeout(() => {
open.value = false
}, 600)
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import RootComponent from '@/App.vue'
import ArticleEditor from '@/components/ArticleEditor.vue'
vi.mock('@nuxt/ui/composables', () => ({
useOverlay: () => ({ create: () => ({ open: () => Promise.resolve(false) }) }),
useToast: () => ({ add: vi.fn() }),
}))
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountArticleEditor(body = '<p>Hello</p>') {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = vi.fn(() =>
Promise.resolve(
mockResponse(200, {
id: 12,
title: 'T',
body,
images: [],
is_online: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}),
),
) as unknown as typeof fetch
await router.push('/article/12/edit')
const wrapper = mount(RootComponent, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
await router.push('/login')
})
describe('insertImage stamps the inserted image with its numeric id', () => {
it('renders data-image-id on the img and emits the updated body', async () => {
const wrapper = await mountArticleEditor()
const editor = wrapper.findComponent(ArticleEditor)
;(
editor.vm as unknown as { insertImage: (src: string, alt: string, id: number) => void }
).insertImage('/static/uploadedImages/photo.png', 'alt text', 10)
await flushPromises()
expect(editor.props('modelValue')).toContain(
'<img src="/static/uploadedImages/photo.png" alt="alt text" data-image-id="10">',
)
})
})
describe('removeImage removes only the node whose data-image-id matches', () => {
it('deletes the matching image node and leaves the rest of the content', async () => {
const wrapper = await mountArticleEditor()
const editor = wrapper.findComponent(ArticleEditor)
const vm = editor.vm as unknown as {
insertImage: (src: string, alt: string, id: number) => void
removeImage: (id: number) => void
}
vm.insertImage('/static/uploadedImages/a.png', '', 10)
await flushPromises()
vm.insertImage('/static/uploadedImages/b.png', '', 11)
await flushPromises()
vm.removeImage(10)
await flushPromises()
const body = editor.props('modelValue') as string
expect(body).not.toContain('data-image-id="10"')
expect(body).toContain('data-image-id="11"')
})
it('does nothing when no image carries the given id', async () => {
const wrapper = await mountArticleEditor('<p>Hello</p>')
const editor = wrapper.findComponent(ArticleEditor)
const vm = editor.vm as unknown as { removeImage: (id: number) => void }
vm.removeImage(999)
await flushPromises()
expect(editor.emitted('update:modelValue')).toBeFalsy()
})
})
@@ -112,7 +112,7 @@ const Host = defineComponent({
const open = ref(false)
return {
open,
onUploaded: (location: string) => emit('uploaded', location),
onUploaded: (location: string, imageId: number) => emit('uploaded', location, imageId),
onClose: () => emit('close'),
}
},
@@ -309,10 +309,13 @@ describe('A successful upload emits the location and auto-closes 600 ms later',
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
xhr.resolve(201, JSON.stringify({ location: '/static/uploadedImages/north-wall.jpg' }))
xhr.resolve(
201,
JSON.stringify({ id: 7, location: '/static/uploadedImages/north-wall.jpg' }),
)
await vi.advanceTimersByTimeAsync(0)
expect(wrapper.emitted('uploaded')).toEqual([['/static/uploadedImages/north-wall.jpg']])
expect(wrapper.emitted('uploaded')).toEqual([['/static/uploadedImages/north-wall.jpg', 7]])
expect(document.body.textContent).toContain('Image added')
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
+6 -3
View File
@@ -6,7 +6,7 @@ export interface UploadProgress {
}
export type UploadOutcome =
| { kind: 'success'; location: string }
| { kind: 'success'; location: string; id: number }
| { kind: 'rejected'; body: string }
| { kind: 'failed' }
@@ -32,8 +32,11 @@ export function useImageUpload() {
request.addEventListener('load', () => {
activeRequest = null
if (request.status === 201) {
const body = JSON.parse(request.responseText) as { location: string }
resolve({ kind: 'success', location: body.location })
const body = JSON.parse(request.responseText) as {
id: number
location: string
}
resolve({ kind: 'success', location: body.location, id: body.id })
return
}
if (request.status === 400) {
+248
View File
@@ -0,0 +1,248 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { ArticleImage, findArticleImagePos } from '../articleImage'
function mountHarness(onRequestRemove = vi.fn()) {
let editor: Editor
const Harness = defineComponent({
components: { EditorContent },
setup() {
editor = new Editor({
extensions: [StarterKit, ArticleImage.configure({ onRequestRemove })],
content: '<p>Hello</p>',
})
return { editor }
},
template: '<EditorContent :editor="editor" />',
})
const wrapper = mount(Harness, { attachTo: document.body })
return { wrapper, onRequestRemove, getEditor: () => editor! }
}
function deleteButton() {
return document.body.querySelector<HTMLButtonElement>('[data-testid="remove-image-button"]')
}
function flush() {
return new Promise((resolve) => setTimeout(resolve, 20))
}
function dispatchKeydown(editor: Editor, key: string) {
editor.view.dom.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
)
}
let activeWrapper: ReturnType<typeof mountHarness>['wrapper'] | null = null
afterEach(() => {
activeWrapper?.unmount()
activeWrapper = null
})
describe('Selecting an inserted image reveals its delete button', () => {
it('shows the delete button when selected and hides it once deselected', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
await flush()
expect(deleteButton()).toBeTruthy()
editor.commands.setTextSelection(0)
await flush()
expect(deleteButton()).toBeFalsy()
})
})
describe('An image with no data-image-id gets no delete affordance', () => {
it('renders no delete button even when the node is selected', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setImage({ src: 'legacy.png', alt: '' })
let pos: number | null = null
editor.state.doc.descendants((node, nodePos) => {
if (node.type.name === 'image') {
pos = nodePos
}
})
editor.commands.setNodeSelection(pos as number)
await flush()
expect(deleteButton()).toBeFalsy()
})
})
describe('Activating the delete button triggers removal without deleting the node', () => {
it('calls onRequestRemove with the image id and leaves the node in place', async () => {
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
await flush()
deleteButton()?.click()
expect(onRequestRemove).toHaveBeenCalledWith(10)
expect(findArticleImagePos(editor, 10)).not.toBeNull()
})
})
describe('Pressing Backspace on a selected image triggers removal without deleting the node', () => {
it('calls onRequestRemove and leaves the image node in the document', () => {
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
dispatchKeydown(editor, 'Backspace')
expect(onRequestRemove).toHaveBeenCalledWith(10)
expect(findArticleImagePos(editor, 10)).not.toBeNull()
})
})
describe('Pressing Delete on a selected image triggers removal without deleting the node', () => {
it('calls onRequestRemove and leaves the image node in the document', () => {
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
dispatchKeydown(editor, 'Delete')
expect(onRequestRemove).toHaveBeenCalledWith(10)
expect(findArticleImagePos(editor, 10)).not.toBeNull()
})
})
describe('Backspace on a pre-existing image with no data-image-id is not intercepted', () => {
it('does not call onRequestRemove, letting native deletion proceed', () => {
const { wrapper, getEditor, onRequestRemove } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setImage({ src: 'legacy.png', alt: '' })
let pos: number | null = null
editor.state.doc.descendants((node, nodePos) => {
if (node.type.name === 'image') {
pos = nodePos
}
})
editor.commands.setNodeSelection(pos as number)
dispatchKeydown(editor, 'Backspace')
expect(onRequestRemove).not.toHaveBeenCalled()
})
})
describe('The custom image node view renders as a block-level element', () => {
it('uses a div wrapper so consecutive images stack vertically', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
await flush()
const node = document.body.querySelector<HTMLElement>(
'[data-testid="article-editor-image"]',
)
expect(node?.tagName).toBe('DIV')
expect(node ? getComputedStyle(node).display : null).toBe('block')
})
})
describe('Selecting an image shows a visible selection outline', () => {
it('applies the selected outline classes to the wrapper when the node is selected', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
const pos = findArticleImagePos(editor, 10) as number
editor.commands.setNodeSelection(pos)
await flush()
const node = document.body.querySelector<HTMLElement>(
'[data-testid="article-editor-image"]',
)
expect(node?.classList.contains('outline-primary')).toBe(true)
})
})
describe('The custom image node view preserves width, height and title attributes', () => {
it('renders width, height and title on the img element', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.insertContent({
type: 'image',
attrs: {
src: 'photo.png',
alt: 'alt text',
title: 'a title',
width: 320,
height: 240,
'data-image-id': 10,
},
})
await flush()
const img = document.body.querySelector<HTMLImageElement>(
'[data-testid="article-editor-image"] img',
)
expect(img?.getAttribute('width')).toBe('320')
expect(img?.getAttribute('height')).toBe('240')
expect(img?.getAttribute('title')).toBe('a title')
})
})
describe('The image node is not natively draggable', () => {
it('disables native dragging on the img element', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'photo.png', alt: '', imageId: 10 })
await flush()
const node = document.body.querySelector<HTMLElement>(
'[data-testid="article-editor-image"]',
)
const img = node?.querySelector<HTMLImageElement>('img')
expect(img?.getAttribute('draggable')).toBe('false')
})
})
describe('removeImage removes the matching image node from the document', () => {
it('deletes only the node whose data-image-id matches', () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: 'a.png', alt: '', imageId: 10 })
editor.commands.setArticleImage({ src: 'b.png', alt: '', imageId: 11 })
const pos = findArticleImagePos(editor, 10) as number
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + 1 })
.run()
expect(findArticleImagePos(editor, 10)).toBeNull()
expect(findArticleImagePos(editor, 11)).not.toBeNull()
})
})
+120
View File
@@ -0,0 +1,120 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import Image from '@tiptap/extension-image'
import { NodeSelection } from '@tiptap/pm/state'
import { VueNodeViewRenderer } from '@tiptap/vue-3'
import ArticleEditorImageNode from '@/components/ArticleEditorImageNode.vue'
export interface ArticleImageOptions {
onRequestRemove: (imageId: number) => void
}
export interface ArticleImageEditorLike {
state: {
selection: unknown
doc: ProseMirrorNode
}
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
articleImage: {
setArticleImage: (options: { src: string; alt?: string; imageId: number }) => ReturnType
}
}
}
export function articleImageId(node: { attrs: Record<string, unknown> }): number | null {
const raw = node.attrs['data-image-id']
if (raw === null || raw === undefined || raw === '') {
return null
}
const id = Number(raw)
return Number.isNaN(id) ? null : id
}
export function selectedArticleImageId(editor: ArticleImageEditorLike): number | null {
const selection = editor.state.selection
if (!(selection instanceof NodeSelection)) {
return null
}
if (selection.node.type.name !== 'image') {
return null
}
return articleImageId(selection.node)
}
export function findArticleImagePos(
editor: ArticleImageEditorLike,
imageId: number,
): number | null {
let targetPos: number | null = null
editor.state.doc.descendants((node, pos) => {
if (targetPos !== null) {
return false
}
if (node.type.name === 'image' && articleImageId(node) === imageId) {
targetPos = pos
return false
}
return true
})
return targetPos
}
export const ArticleImage = Image.extend<ArticleImageOptions>({
addOptions() {
return {
...this.parent?.(),
onRequestRemove: () => {},
}
},
addAttributes() {
return {
...this.parent?.(),
'data-image-id': {
default: null,
parseHTML: (element) => element.getAttribute('data-image-id'),
renderHTML: (attributes) => {
if (!attributes['data-image-id']) {
return {}
}
return { 'data-image-id': attributes['data-image-id'] }
},
},
}
},
addNodeView() {
return VueNodeViewRenderer(ArticleEditorImageNode)
},
addCommands() {
return {
...this.parent?.(),
setArticleImage:
(options: { src: string; alt?: string; imageId: number }) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: {
src: options.src,
alt: options.alt,
'data-image-id': options.imageId,
},
})
},
}
},
addKeyboardShortcuts() {
const triggerRemoval = () => {
const imageId = selectedArticleImageId(this.editor)
if (imageId === null) {
return false
}
this.options.onRequestRemove(imageId)
return true
}
return {
Backspace: triggerRemoval,
Delete: triggerRemoval,
}
},
})
+34 -2
View File
@@ -221,8 +221,8 @@ function onEditorDrop(event: DragEvent) {
openImageUploader(file)
}
function onImageUploaded(location: string) {
editorRef.value?.insertImage(location, '')
function onImageUploaded(location: string, imageId: number) {
editorRef.value?.insertImage(location, '', imageId)
toast.add({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -231,6 +231,37 @@ function onImageUploaded(location: string) {
})
}
async function performRemoveImage(imageId: number) {
try {
await request(`/v1/article/${articleId}/image/${imageId}`, { method: 'DELETE' })
} catch {
throw new Error("Couldn't remove the image, so it's still there. Try again in a moment.")
}
editorRef.value?.removeImage(imageId)
toast.add({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image removed',
description: 'Save to keep this change.',
})
}
async function onRequestRemoveImage(imageId: number) {
await overlay
.create(ConfirmDialog, {
props: {
title: 'Remove this image?',
description: "This image will be removed from the article. This can't be undone.",
confirmLabel: 'Remove image',
cancelLabel: 'Cancel',
pendingLabel: 'Removing…',
tone: 'danger',
action: () => performRemoveImage(imageId),
},
})
.open()
}
function onImageUploaderClosed() {
imageUploaderFile.value = null
}
@@ -426,6 +457,7 @@ onBeforeRouteLeave(async () => {
:article-id="articleId"
@update:model-value="draft.edit"
@request-image="onRequestImage"
@request-remove-image="onRequestRemoveImage"
>
<template #footer>
<CharacterCounter
+151 -131
View File
@@ -10,11 +10,7 @@ import RootComponent from '@/App.vue'
const FIXED_NOW = '2026-09-26T11:02:00Z'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -28,9 +24,7 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -61,17 +55,15 @@ function articleFixture(overrides: Partial<Article> = {}): Article {
function createOverlayMock() {
let props: Record<string, unknown> | null = null
let resolveOpen: ((value: unknown) => void) | null = null
const create = vi.fn(
(_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
},
)
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
})
return {
create,
getProps: () => props,
@@ -120,13 +112,8 @@ function articleEditorBody(wrapper: Awaited<ReturnType<typeof mount>>) {
return wrapper.findComponent(ArticleEditor).props('modelValue')
}
async function typeBody(
wrapper: Awaited<ReturnType<typeof mount>>,
body: string,
) {
await wrapper
.findComponent(ArticleEditor)
.vm.$emit('update:modelValue', body)
async function typeBody(wrapper: Awaited<ReturnType<typeof mount>>, body: string) {
await wrapper.findComponent(ArticleEditor).vm.$emit('update:modelValue', body)
await flushPromises()
}
@@ -161,49 +148,35 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
new Promise((resolve) => {
resolveGet = () =>
resolve(mockResponse(200, articleFixture()))
resolveGet = () => resolve(mockResponse(200, articleFixture()))
}),
})
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false)
await vi.advanceTimersByTimeAsync(150)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
true,
)
expect(wrapper.find('[data-testid="skeleton-content"]').exists()).toBe(
true,
)
expect(wrapper.find('[data-testid="skeleton-details"]').exists()).toBe(
true,
)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="skeleton-content"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="skeleton-details"]').exists()).toBe(true)
resolveGet?.()
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false)
expect(wrapper.text()).toContain('How we price a bathroom renovation')
})
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
Promise.resolve(mockResponse(200, articleFixture())),
'GET /v1/article/12': () => Promise.resolve(mockResponse(200, articleFixture())),
})
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false)
expect(wrapper.text()).toContain('How we price a bathroom renovation')
})
})
@@ -216,19 +189,14 @@ describe('Saved — a freshly loaded article, draft or online', () => {
'reads the Saved state for is_online $is_online',
async ({ is_online, badge, publishLabel }) => {
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
mockResponse(200, articleFixture({ is_online })),
'GET /v1/article/12': () => mockResponse(200, articleFixture({ is_online })),
})
expect(wrapper.text()).toContain('Articles')
expect(wrapper.text()).toContain(
'How we price a bathroom renovation',
)
expect(wrapper.text()).toContain('How we price a bathroom renovation')
expect(wrapper.text()).toContain(badge)
expect(wrapper.text()).toContain('Saved at 11:02')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
expect(wrapper.text()).toContain(publishLabel)
expect(wrapper.text()).toContain('4 318 / 10 000')
},
@@ -244,9 +212,7 @@ describe('Unsaved changes — any edit marks the article dirty', () => {
await typeBody(wrapper, paragraphOfLength(4320))
expect(wrapper.text()).toContain('Unsaved changes')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
expect(wrapper.text()).toContain('Save and publish')
})
})
@@ -274,9 +240,7 @@ describe('Saving — Save sends the PATCH and disables itself while in flight',
body: paragraphOfLength(4320),
})
expect(wrapper.text()).toContain('Saving…')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
})
})
@@ -296,9 +260,7 @@ describe('Ctrl/⌘ S saves the same way as clicking Save', () => {
throw new Error(`Unexpected fetch call: ${routeFor(url, init)}`)
}) as unknown as typeof fetch
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 's', ctrlKey: true }),
)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }))
await flushPromises()
expect(JSON.parse(patchBody ?? '{}')).toEqual({
@@ -335,9 +297,7 @@ describe('A successful save returns to the Saved state with the new time', () =>
await flushPromises()
expect(wrapper.text()).toContain('Saved at 11:30')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
})
})
@@ -374,14 +334,8 @@ describe('Save failed — a 500 or a network failure keeps the changes and offer
await flushPromises()
expect(wrapper.text()).toContain('Save failed')
expect(
wrapper
.findAll('button')
.some((button) => button.text() === 'Retry'),
).toBe(true)
expect(articleEditorBody(wrapper)).toBe(
paragraph('Revised opening paragraph.'),
)
expect(wrapper.findAll('button').some((button) => button.text() === 'Retry')).toBe(true)
expect(articleEditorBody(wrapper)).toBe(paragraph('Revised opening paragraph.'))
})
})
@@ -406,9 +360,7 @@ describe('Retry resends the same body and can recover', () => {
await saveButton(wrapper).trigger('click')
await flushPromises()
const retryButton = wrapper
.findAll('button')
.find((button) => button.text() === 'Retry')
const retryButton = wrapper.findAll('button').find((button) => button.text() === 'Retry')
await retryButton?.trigger('click')
await flushPromises()
@@ -444,14 +396,8 @@ describe('Near the limit — the counter warns at 9000 characters without blocki
await typeBody(wrapper, paragraphOfLength(9240))
expect(wrapper.text()).toContain('9 240 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(true)
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
})
})
@@ -466,26 +412,16 @@ describe('Too long — over 10000 characters disables Save and Publish until sho
expect(wrapper.text()).toContain(
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
)
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
const publishButton = wrapper
.findAll('button')
.find(
(button) =>
button.text() === 'Publish' ||
button.text() === 'Save and publish',
)
expect((publishButton?.element as HTMLButtonElement).disabled).toBe(
true,
)
.find((button) => button.text() === 'Publish' || button.text() === 'Save and publish')
expect((publishButton?.element as HTMLButtonElement).disabled).toBe(true)
await typeBody(wrapper, paragraphOfLength(9000))
expect(wrapper.text()).not.toContain('characters over')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
})
})
@@ -501,12 +437,10 @@ describe('Preview shows the rendered title and body without the editing surface'
await flushPromises()
expect(wrapper.findComponent(ArticleEditor).exists()).toBe(false)
expect(
wrapper.find('[data-testid="article-preview"]').text(),
).toContain('How we price a bathroom renovation')
expect(
wrapper.find('[data-testid="article-preview"]').html(),
).toContain('Some body')
expect(wrapper.find('[data-testid="article-preview"]').text()).toContain(
'How we price a bathroom renovation',
)
expect(wrapper.find('[data-testid="article-preview"]').html()).toContain('Some body')
await previewSwitch.trigger('click')
await flushPromises()
@@ -523,24 +457,18 @@ describe('Offline blocks Save and Publish, and recovers when the connection retu
})
await typeBody(wrapper, paragraphOfLength(4320))
const onLineSpy = vi
.spyOn(navigator, 'onLine', 'get')
.mockReturnValue(false)
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
expect(wrapper.text()).toContain('Waiting for connection')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
onLineSpy.mockReturnValue(true)
window.dispatchEvent(new Event('online'))
await flushPromises()
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
expect(wrapper.text()).toContain('Unsaved changes')
onLineSpy.mockRestore()
})
@@ -553,9 +481,7 @@ describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds',
})
await typeBody(wrapper, paragraphOfLength(4320))
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
expect(articlesLink).toBeTruthy()
const navigation = router.push('/article')
@@ -584,9 +510,7 @@ describe('Leaving with unsaved changes, "Keep editing" stays on the page', () =>
})
await typeBody(wrapper, paragraphOfLength(4320))
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
expect(articlesLink).toBeTruthy()
const navigation = router.push('/article')
@@ -627,9 +551,7 @@ describe('Leaving a clean article never prompts', () => {
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
await articlesLink?.trigger('click')
await flushPromises()
@@ -644,18 +566,14 @@ describe('Session expired mid-save keeps the draft and restores it after logging
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () => mockResponse(401),
})
const body = paragraph(
'Paragraph added just before the session expired.',
)
const body = paragraph('Paragraph added just before the session expired.')
await typeBody(wrapper, body)
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBe(body)
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query.redirect).toBe(
'/article/12/edit',
)
expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit')
expect(router.currentRoute.value.query.reason).toBe('expired')
const session = useSessionStore()
@@ -685,6 +603,108 @@ describe('Session expired mid-save keeps the draft and restores it after logging
})
})
function articleWithImageFixture(overrides: Partial<Article> = {}) {
return articleFixture({
body: '<p>Body</p><img src="/static/uploadedImages/before.png" alt="" data-image-id="10">',
...overrides,
})
}
async function requestRemoveImage(wrapper: Awaited<ReturnType<typeof mount>>, imageId = 10) {
await wrapper.findComponent(ArticleEditor).vm.$emit('request-remove-image', imageId)
await flushPromises()
}
describe('Requesting removal of an inline image opens the confirm dialog', () => {
it('opens ConfirmDialog with the exact copy', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.title).toBe('Remove this image?')
expect(props.description).toBe(
"This image will be removed from the article. This can't be undone.",
)
expect(props.confirmLabel).toBe('Remove image')
expect(props.cancelLabel).toBe('Cancel')
expect(props.pendingLabel).toBe('Removing…')
expect(props.tone).toBe('danger')
})
})
describe('Confirming removes the image, marks the draft dirty, and shows a success toast', () => {
it('removes the image from the content and reads Unsaved changes', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'DELETE /v1/article/12/image/10': () => mockResponse(204),
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(articleEditorBody(wrapper)).not.toContain('data-image-id="10"')
expect(wrapper.text()).toContain('Unsaved changes')
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image removed',
description: 'Save to keep this change.',
})
})
})
describe('A failed removal keeps the image and shows the dialog inline error', () => {
it.each([
{ name: 'a 404 response', handler: () => mockResponse(404) },
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('rejects with the inline error message for $name', async ({ handler }) => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'DELETE /v1/article/12/image/10': handler,
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { action: () => Promise<void> }
await expect(props.action()).rejects.toThrow(
"Couldn't remove the image, so it's still there. Try again in a moment.",
)
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
expect(wrapper.text()).not.toContain('Unsaved changes')
})
})
describe('Confirming a removal started by Backspace behaves exactly like confirming one started by the button', () => {
it('removes the image, marks the draft dirty, and shows the same success toast', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'DELETE /v1/article/12/image/10': () => mockResponse(204),
})
await requestRemoveImage(wrapper)
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(articleEditorBody(wrapper)).not.toContain('data-image-id="10"')
expect(wrapper.text()).toContain('Unsaved changes')
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image removed',
description: 'Save to keep this change.',
})
})
})
describe('An unknown or deleted article id replaces the editor with an error state', () => {
it('shows the not-found error state and navigates to /article', async () => {
const wrapper = await mountEditor(