feat: editor image removal QOLs
This commit is contained in:
1 parent
0fc00ceeff
commit
103bd38453
23 files changed
+1391
-144
No files matched your search
@@ -17,13 +17,15 @@ type Handler struct {
|
|||||||
CreateUseCase *useCases.CreateImageUseCase
|
CreateUseCase *useCases.CreateImageUseCase
|
||||||
GetUseCase *useCases.GetArticleUseCase
|
GetUseCase *useCases.GetArticleUseCase
|
||||||
UpdateUseCase *useCases.UpdateArticleUseCase
|
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{
|
return &Handler{
|
||||||
CreateUseCase: create,
|
CreateUseCase: create,
|
||||||
GetUseCase: get,
|
GetUseCase: get,
|
||||||
UpdateUseCase: update,
|
UpdateUseCase: update,
|
||||||
|
DeleteUseCase: deleteUseCase,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +80,7 @@ func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
writeInternalServerError(w)
|
writeInternalServerError(w)
|
||||||
return
|
return
|
||||||
@@ -87,3 +89,33 @@ func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
_, _ = w.Write(responseJson)
|
_, _ = 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)
|
||||||
|
}
|
||||||
@@ -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 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 IdUint32ErrorMessage = "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
|
||||||
const ArticleNotFoundErrorMessage = "Article not found"
|
const ArticleNotFoundErrorMessage = "Article not found"
|
||||||
|
const ImageNotFoundErrorMessage = "Image not found"
|
||||||
const ArticleTitleErrorMessage = "Article title must be between 3 and 200 characters."
|
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 RegistrationClosedErrorMessage = "You are not allowed to create a user. Log in or reset database."
|
||||||
const RegistrationFailedErrorMessage = "Registration failed. Please try again later."
|
const RegistrationFailedErrorMessage = "Registration failed. Please try again later."
|
||||||
|
|||||||
+2
-1
@@ -33,7 +33,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
|
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.Group(func(r chi.Router) {
|
||||||
r.Get("/", articleHandler.ListArticles)
|
r.Get("/", articleHandler.ListArticles)
|
||||||
@@ -49,6 +49,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
|
|||||||
r.Patch("/{id}/publish", articleHandler.PublishArticle)
|
r.Patch("/{id}/publish", articleHandler.PublishArticle)
|
||||||
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
|
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
|
||||||
r.Post("/{id}/image", imageHandler.PostImage)
|
r.Post("/{id}/image", imageHandler.PostImage)
|
||||||
|
r.Delete("/{id}/image/{imageId}", imageHandler.DeleteImage)
|
||||||
})
|
})
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -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.
|
||||||
@@ -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 |
|
||||||
@@ -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.
|
||||||
@@ -37,9 +37,12 @@ Feature: Uploading an image to an article
|
|||||||
back to a generic 500
|
back to a generic 500
|
||||||
And PostImage reads the uploaded file from the multipart form field
|
And PostImage reads the uploaded file from the multipart form field
|
||||||
"file"
|
"file"
|
||||||
And on success PostImage answers 201 with a JSON body {"location": ...}
|
And on success PostImage answers 201 with a JSON body
|
||||||
holding the created image's public path, e.g.
|
{"id": ..., "location": ...} holding the created image's numeric id
|
||||||
"/static/uploadedImages/<stored-filename>"
|
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
|
Scenario: Uploading an allowed image type to an existing article succeeds
|
||||||
Given a valid jwt cookie for a user with id 1
|
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
|
Then the response status is 201
|
||||||
And the response body is JSON with a "location" field whose value is
|
And the response body is JSON with a "location" field whose value is
|
||||||
"/static/uploadedImages/before.png"
|
"/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
|
And the image repository contains a new image whose Path is
|
||||||
"/static/uploadedImages/before.png"
|
"/static/uploadedImages/before.png"
|
||||||
And the article repository's article with id 1 has that new image's id
|
And the article repository's article with id 1 has that new image's id
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ func newUploadArticleImageRouter(articleRepo *mockArticleRepo, imageRepo *mockIm
|
|||||||
useCases.NewCreateImageUseCase(imageRepo),
|
useCases.NewCreateImageUseCase(imageRepo),
|
||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo),
|
||||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
useCases.NewUpdateArticleUseCase(articleRepo),
|
||||||
|
useCases.NewDeleteImageUseCase(imageRepo),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
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())
|
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 {
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
t.Fatalf("failed to decode response: %v", err)
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
}
|
}
|
||||||
@@ -120,6 +121,10 @@ func TestUploadArticleImageHttp(t *testing.T) {
|
|||||||
if !found {
|
if !found {
|
||||||
t.Fatalf("expected the image repository to contain the new image")
|
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]
|
updatedArticle := articleRepo.articles[1]
|
||||||
imageAttached := false
|
imageAttached := false
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mo
|
|||||||
useCases.NewCreateImageUseCase(imageRepo),
|
useCases.NewCreateImageUseCase(imageRepo),
|
||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo),
|
||||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
useCases.NewUpdateArticleUseCase(articleRepo),
|
||||||
|
useCases.NewDeleteImageUseCase(imageRepo),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tiptap/core": "3.31.3",
|
"@tiptap/core": "3.31.3",
|
||||||
"@tiptap/extension-drag-handle-vue-3": "^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",
|
"@vueuse/core": "14.4.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"shiki": "^4.4.3",
|
"shiki": "^4.4.3",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tiptap/core": "3.31.3",
|
"@tiptap/core": "3.31.3",
|
||||||
"@tiptap/extension-drag-handle-vue-3": "^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",
|
"@vueuse/core": "14.4.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"shiki": "^4.4.3",
|
"shiki": "^4.4.3",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
|||||||
import type { EditorToolbarItem } from '@nuxt/ui'
|
import type { EditorToolbarItem } from '@nuxt/ui'
|
||||||
import type { Editor } from '@tiptap/core'
|
import type { Editor } from '@tiptap/core'
|
||||||
import { ArticleIndent } from '@/lib/articleIndent'
|
import { ArticleIndent } from '@/lib/articleIndent'
|
||||||
|
import { ArticleImage, findArticleImagePos } from '@/lib/articleImage'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -18,10 +19,16 @@ const props = withDefaults(
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: string]
|
'update:modelValue': [value: string]
|
||||||
'request-image': []
|
'request-image': []
|
||||||
|
'request-remove-image': [imageId: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const editorInstance = ref<Editor | null>(null)
|
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) {
|
function captureEditor(editor: Editor) {
|
||||||
editorInstance.value = editor
|
editorInstance.value = editor
|
||||||
@@ -198,8 +205,22 @@ defineExpose({
|
|||||||
focus() {
|
focus() {
|
||||||
editorInstance.value?.commands.focus()
|
editorInstance.value?.commands.focus()
|
||||||
},
|
},
|
||||||
insertImage(src: string, alt: string) {
|
insertImage(src: string, alt: string, imageId: number) {
|
||||||
editorInstance.value?.commands.setImage({ src, alt })
|
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>
|
</script>
|
||||||
@@ -209,6 +230,7 @@ defineExpose({
|
|||||||
v-slot="{ editor }"
|
v-slot="{ editor }"
|
||||||
:model-value="props.modelValue"
|
:model-value="props.modelValue"
|
||||||
:extensions="extensions"
|
:extensions="extensions"
|
||||||
|
:image="false"
|
||||||
content-type="html"
|
content-type="html"
|
||||||
:editable="!props.disabled"
|
:editable="!props.disabled"
|
||||||
:ui="{ base: 'article-content' }"
|
: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>
|
||||||
@@ -41,7 +41,7 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
uploaded: [location: string]
|
uploaded: [location: string, imageId: number]
|
||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ async function runUpload(file: File) {
|
|||||||
const outcome = await start(props.articleId, file)
|
const outcome = await start(props.articleId, file)
|
||||||
if (outcome.kind === 'success') {
|
if (outcome.kind === 'success') {
|
||||||
succeeded.value = true
|
succeeded.value = true
|
||||||
emit('uploaded', outcome.location)
|
emit('uploaded', outcome.location, outcome.id)
|
||||||
closeTimer = setTimeout(() => {
|
closeTimer = setTimeout(() => {
|
||||||
open.value = false
|
open.value = false
|
||||||
}, 600)
|
}, 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)
|
const open = ref(false)
|
||||||
return {
|
return {
|
||||||
open,
|
open,
|
||||||
onUploaded: (location: string) => emit('uploaded', location),
|
onUploaded: (location: string, imageId: number) => emit('uploaded', location, imageId),
|
||||||
onClose: () => emit('close'),
|
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))
|
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
|
||||||
const xhr = latestXHR()
|
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)
|
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.body.textContent).toContain('Image added')
|
||||||
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
|
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface UploadProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type UploadOutcome =
|
export type UploadOutcome =
|
||||||
| { kind: 'success'; location: string }
|
| { kind: 'success'; location: string; id: number }
|
||||||
| { kind: 'rejected'; body: string }
|
| { kind: 'rejected'; body: string }
|
||||||
| { kind: 'failed' }
|
| { kind: 'failed' }
|
||||||
|
|
||||||
@@ -32,8 +32,11 @@ export function useImageUpload() {
|
|||||||
request.addEventListener('load', () => {
|
request.addEventListener('load', () => {
|
||||||
activeRequest = null
|
activeRequest = null
|
||||||
if (request.status === 201) {
|
if (request.status === 201) {
|
||||||
const body = JSON.parse(request.responseText) as { location: string }
|
const body = JSON.parse(request.responseText) as {
|
||||||
resolve({ kind: 'success', location: body.location })
|
id: number
|
||||||
|
location: string
|
||||||
|
}
|
||||||
|
resolve({ kind: 'success', location: body.location, id: body.id })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (request.status === 400) {
|
if (request.status === 400) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -221,8 +221,8 @@ function onEditorDrop(event: DragEvent) {
|
|||||||
openImageUploader(file)
|
openImageUploader(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onImageUploaded(location: string) {
|
function onImageUploaded(location: string, imageId: number) {
|
||||||
editorRef.value?.insertImage(location, '')
|
editorRef.value?.insertImage(location, '', imageId)
|
||||||
toast.add({
|
toast.add({
|
||||||
color: 'success',
|
color: 'success',
|
||||||
icon: 'i-lucide-circle-check',
|
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() {
|
function onImageUploaderClosed() {
|
||||||
imageUploaderFile.value = null
|
imageUploaderFile.value = null
|
||||||
}
|
}
|
||||||
@@ -426,6 +457,7 @@ onBeforeRouteLeave(async () => {
|
|||||||
:article-id="articleId"
|
:article-id="articleId"
|
||||||
@update:model-value="draft.edit"
|
@update:model-value="draft.edit"
|
||||||
@request-image="onRequestImage"
|
@request-image="onRequestImage"
|
||||||
|
@request-remove-image="onRequestRemoveImage"
|
||||||
>
|
>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<CharacterCounter
|
<CharacterCounter
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ import RootComponent from '@/App.vue'
|
|||||||
|
|
||||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||||
|
|
||||||
function mockResponse(
|
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
||||||
status: number,
|
|
||||||
body: unknown = null,
|
|
||||||
headers: Record<string, string> = {},
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
ok: status >= 200 && status < 300,
|
ok: status >= 200 && status < 300,
|
||||||
status,
|
status,
|
||||||
@@ -28,9 +24,7 @@ function routeFor(url: string, init?: RequestInit) {
|
|||||||
return `${method} ${url}`
|
return `${method} ${url}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchRouter(
|
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
||||||
handlers: Record<string, () => Response | Promise<Response>>,
|
|
||||||
) {
|
|
||||||
return vi.fn((url: string, init?: RequestInit) => {
|
return vi.fn((url: string, init?: RequestInit) => {
|
||||||
const key = routeFor(url, init)
|
const key = routeFor(url, init)
|
||||||
const handler = handlers[key]
|
const handler = handlers[key]
|
||||||
@@ -61,8 +55,7 @@ function articleFixture(overrides: Partial<Article> = {}): Article {
|
|||||||
function createOverlayMock() {
|
function createOverlayMock() {
|
||||||
let props: Record<string, unknown> | null = null
|
let props: Record<string, unknown> | null = null
|
||||||
let resolveOpen: ((value: unknown) => void) | null = null
|
let resolveOpen: ((value: unknown) => void) | null = null
|
||||||
const create = vi.fn(
|
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
|
||||||
(_component: unknown, options: { props: Record<string, unknown> }) => {
|
|
||||||
props = options.props
|
props = options.props
|
||||||
return {
|
return {
|
||||||
open: () =>
|
open: () =>
|
||||||
@@ -70,8 +63,7 @@ function createOverlayMock() {
|
|||||||
resolveOpen = resolve
|
resolveOpen = resolve
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
create,
|
create,
|
||||||
getProps: () => props,
|
getProps: () => props,
|
||||||
@@ -120,13 +112,8 @@ function articleEditorBody(wrapper: Awaited<ReturnType<typeof mount>>) {
|
|||||||
return wrapper.findComponent(ArticleEditor).props('modelValue')
|
return wrapper.findComponent(ArticleEditor).props('modelValue')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function typeBody(
|
async function typeBody(wrapper: Awaited<ReturnType<typeof mount>>, body: string) {
|
||||||
wrapper: Awaited<ReturnType<typeof mount>>,
|
await wrapper.findComponent(ArticleEditor).vm.$emit('update:modelValue', body)
|
||||||
body: string,
|
|
||||||
) {
|
|
||||||
await wrapper
|
|
||||||
.findComponent(ArticleEditor)
|
|
||||||
.vm.$emit('update:modelValue', body)
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,49 +148,35 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
|
|||||||
const wrapper = await mountEditor({
|
const wrapper = await mountEditor({
|
||||||
'GET /v1/article/12': () =>
|
'GET /v1/article/12': () =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
resolveGet = () =>
|
resolveGet = () => resolve(mockResponse(200, articleFixture()))
|
||||||
resolve(mockResponse(200, articleFixture()))
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
|
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false)
|
||||||
false,
|
|
||||||
)
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(150)
|
await vi.advanceTimersByTimeAsync(150)
|
||||||
|
|
||||||
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
|
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(true)
|
||||||
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-content"]').exists()).toBe(
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
expect(wrapper.find('[data-testid="skeleton-details"]').exists()).toBe(
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
|
|
||||||
resolveGet?.()
|
resolveGet?.()
|
||||||
await vi.advanceTimersByTimeAsync(0)
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
|
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false)
|
||||||
false,
|
|
||||||
)
|
|
||||||
expect(wrapper.text()).toContain('How we price a bathroom renovation')
|
expect(wrapper.text()).toContain('How we price a bathroom renovation')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
|
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
|
||||||
const wrapper = await mountEditor({
|
const wrapper = await mountEditor({
|
||||||
'GET /v1/article/12': () =>
|
'GET /v1/article/12': () => Promise.resolve(mockResponse(200, articleFixture())),
|
||||||
Promise.resolve(mockResponse(200, articleFixture())),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(0)
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
|
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false)
|
||||||
false,
|
|
||||||
)
|
|
||||||
expect(wrapper.text()).toContain('How we price a bathroom renovation')
|
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',
|
'reads the Saved state for is_online $is_online',
|
||||||
async ({ is_online, badge, publishLabel }) => {
|
async ({ is_online, badge, publishLabel }) => {
|
||||||
const wrapper = await mountEditor({
|
const wrapper = await mountEditor({
|
||||||
'GET /v1/article/12': () =>
|
'GET /v1/article/12': () => mockResponse(200, articleFixture({ is_online })),
|
||||||
mockResponse(200, articleFixture({ is_online })),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Articles')
|
expect(wrapper.text()).toContain('Articles')
|
||||||
expect(wrapper.text()).toContain(
|
expect(wrapper.text()).toContain('How we price a bathroom renovation')
|
||||||
'How we price a bathroom renovation',
|
|
||||||
)
|
|
||||||
expect(wrapper.text()).toContain(badge)
|
expect(wrapper.text()).toContain(badge)
|
||||||
expect(wrapper.text()).toContain('Saved at 11:02')
|
expect(wrapper.text()).toContain('Saved at 11:02')
|
||||||
expect(
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
||||||
(saveButton(wrapper).element as HTMLButtonElement).disabled,
|
|
||||||
).toBe(true)
|
|
||||||
expect(wrapper.text()).toContain(publishLabel)
|
expect(wrapper.text()).toContain(publishLabel)
|
||||||
expect(wrapper.text()).toContain('4 318 / 10 000')
|
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))
|
await typeBody(wrapper, paragraphOfLength(4320))
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Unsaved changes')
|
expect(wrapper.text()).toContain('Unsaved changes')
|
||||||
expect(
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
|
||||||
(saveButton(wrapper).element as HTMLButtonElement).disabled,
|
|
||||||
).toBe(false)
|
|
||||||
expect(wrapper.text()).toContain('Save and publish')
|
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),
|
body: paragraphOfLength(4320),
|
||||||
})
|
})
|
||||||
expect(wrapper.text()).toContain('Saving…')
|
expect(wrapper.text()).toContain('Saving…')
|
||||||
expect(
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
||||||
(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)}`)
|
throw new Error(`Unexpected fetch call: ${routeFor(url, init)}`)
|
||||||
}) as unknown as typeof fetch
|
}) as unknown as typeof fetch
|
||||||
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }))
|
||||||
new KeyboardEvent('keydown', { key: 's', ctrlKey: true }),
|
|
||||||
)
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(JSON.parse(patchBody ?? '{}')).toEqual({
|
expect(JSON.parse(patchBody ?? '{}')).toEqual({
|
||||||
@@ -335,9 +297,7 @@ describe('A successful save returns to the Saved state with the new time', () =>
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Saved at 11:30')
|
expect(wrapper.text()).toContain('Saved at 11:30')
|
||||||
expect(
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
||||||
(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()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Save failed')
|
expect(wrapper.text()).toContain('Save failed')
|
||||||
expect(
|
expect(wrapper.findAll('button').some((button) => button.text() === 'Retry')).toBe(true)
|
||||||
wrapper
|
expect(articleEditorBody(wrapper)).toBe(paragraph('Revised opening paragraph.'))
|
||||||
.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 saveButton(wrapper).trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const retryButton = wrapper
|
const retryButton = wrapper.findAll('button').find((button) => button.text() === 'Retry')
|
||||||
.findAll('button')
|
|
||||||
.find((button) => button.text() === 'Retry')
|
|
||||||
await retryButton?.trigger('click')
|
await retryButton?.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -444,14 +396,8 @@ describe('Near the limit — the counter warns at 9000 characters without blocki
|
|||||||
await typeBody(wrapper, paragraphOfLength(9240))
|
await typeBody(wrapper, paragraphOfLength(9240))
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('9 240 / 10 000')
|
expect(wrapper.text()).toContain('9 240 / 10 000')
|
||||||
expect(
|
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(true)
|
||||||
wrapper
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
|
||||||
.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(
|
expect(wrapper.text()).toContain(
|
||||||
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
|
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
|
||||||
)
|
)
|
||||||
expect(
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
||||||
(saveButton(wrapper).element as HTMLButtonElement).disabled,
|
|
||||||
).toBe(true)
|
|
||||||
const publishButton = wrapper
|
const publishButton = wrapper
|
||||||
.findAll('button')
|
.findAll('button')
|
||||||
.find(
|
.find((button) => button.text() === 'Publish' || button.text() === 'Save and publish')
|
||||||
(button) =>
|
expect((publishButton?.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
button.text() === 'Publish' ||
|
|
||||||
button.text() === 'Save and publish',
|
|
||||||
)
|
|
||||||
expect((publishButton?.element as HTMLButtonElement).disabled).toBe(
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
|
|
||||||
await typeBody(wrapper, paragraphOfLength(9000))
|
await typeBody(wrapper, paragraphOfLength(9000))
|
||||||
|
|
||||||
expect(wrapper.text()).not.toContain('characters over')
|
expect(wrapper.text()).not.toContain('characters over')
|
||||||
expect(
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
|
||||||
(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()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.findComponent(ArticleEditor).exists()).toBe(false)
|
expect(wrapper.findComponent(ArticleEditor).exists()).toBe(false)
|
||||||
expect(
|
expect(wrapper.find('[data-testid="article-preview"]').text()).toContain(
|
||||||
wrapper.find('[data-testid="article-preview"]').text(),
|
'How we price a bathroom renovation',
|
||||||
).toContain('How we price a bathroom renovation')
|
)
|
||||||
expect(
|
expect(wrapper.find('[data-testid="article-preview"]').html()).toContain('Some body')
|
||||||
wrapper.find('[data-testid="article-preview"]').html(),
|
|
||||||
).toContain('Some body')
|
|
||||||
|
|
||||||
await previewSwitch.trigger('click')
|
await previewSwitch.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -523,24 +457,18 @@ describe('Offline blocks Save and Publish, and recovers when the connection retu
|
|||||||
})
|
})
|
||||||
await typeBody(wrapper, paragraphOfLength(4320))
|
await typeBody(wrapper, paragraphOfLength(4320))
|
||||||
|
|
||||||
const onLineSpy = vi
|
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
||||||
.spyOn(navigator, 'onLine', 'get')
|
|
||||||
.mockReturnValue(false)
|
|
||||||
window.dispatchEvent(new Event('offline'))
|
window.dispatchEvent(new Event('offline'))
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Waiting for connection')
|
expect(wrapper.text()).toContain('Waiting for connection')
|
||||||
expect(
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
||||||
(saveButton(wrapper).element as HTMLButtonElement).disabled,
|
|
||||||
).toBe(true)
|
|
||||||
|
|
||||||
onLineSpy.mockReturnValue(true)
|
onLineSpy.mockReturnValue(true)
|
||||||
window.dispatchEvent(new Event('online'))
|
window.dispatchEvent(new Event('online'))
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(
|
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
|
||||||
(saveButton(wrapper).element as HTMLButtonElement).disabled,
|
|
||||||
).toBe(false)
|
|
||||||
expect(wrapper.text()).toContain('Unsaved changes')
|
expect(wrapper.text()).toContain('Unsaved changes')
|
||||||
onLineSpy.mockRestore()
|
onLineSpy.mockRestore()
|
||||||
})
|
})
|
||||||
@@ -553,9 +481,7 @@ describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds',
|
|||||||
})
|
})
|
||||||
await typeBody(wrapper, paragraphOfLength(4320))
|
await typeBody(wrapper, paragraphOfLength(4320))
|
||||||
|
|
||||||
const articlesLink = wrapper
|
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
|
||||||
.findAll('a')
|
|
||||||
.find((a) => a.attributes('href') === '/article')
|
|
||||||
expect(articlesLink).toBeTruthy()
|
expect(articlesLink).toBeTruthy()
|
||||||
|
|
||||||
const navigation = router.push('/article')
|
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))
|
await typeBody(wrapper, paragraphOfLength(4320))
|
||||||
|
|
||||||
const articlesLink = wrapper
|
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
|
||||||
.findAll('a')
|
|
||||||
.find((a) => a.attributes('href') === '/article')
|
|
||||||
expect(articlesLink).toBeTruthy()
|
expect(articlesLink).toBeTruthy()
|
||||||
|
|
||||||
const navigation = router.push('/article')
|
const navigation = router.push('/article')
|
||||||
@@ -627,9 +551,7 @@ describe('Leaving a clean article never prompts', () => {
|
|||||||
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
||||||
})
|
})
|
||||||
|
|
||||||
const articlesLink = wrapper
|
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
|
||||||
.findAll('a')
|
|
||||||
.find((a) => a.attributes('href') === '/article')
|
|
||||||
await articlesLink?.trigger('click')
|
await articlesLink?.trigger('click')
|
||||||
await flushPromises()
|
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()),
|
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
|
||||||
'PATCH /v1/article/12': () => mockResponse(401),
|
'PATCH /v1/article/12': () => mockResponse(401),
|
||||||
})
|
})
|
||||||
const body = paragraph(
|
const body = paragraph('Paragraph added just before the session expired.')
|
||||||
'Paragraph added just before the session expired.',
|
|
||||||
)
|
|
||||||
await typeBody(wrapper, body)
|
await typeBody(wrapper, body)
|
||||||
await saveButton(wrapper).trigger('click')
|
await saveButton(wrapper).trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBe(body)
|
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBe(body)
|
||||||
expect(router.currentRoute.value.path).toBe('/login')
|
expect(router.currentRoute.value.path).toBe('/login')
|
||||||
expect(router.currentRoute.value.query.redirect).toBe(
|
expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit')
|
||||||
'/article/12/edit',
|
|
||||||
)
|
|
||||||
expect(router.currentRoute.value.query.reason).toBe('expired')
|
expect(router.currentRoute.value.query.reason).toBe('expired')
|
||||||
|
|
||||||
const session = useSessionStore()
|
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', () => {
|
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 () => {
|
it('shows the not-found error state and navigates to /article', async () => {
|
||||||
const wrapper = await mountEditor(
|
const wrapper = await mountEditor(
|
||||||
|
|||||||
Reference in new issue
Block a user