feat: article editor core

This commit is contained in:
fsylvain committed 2026-09-27 02:11:38 +02:00
1 parent d493a181e1
commit a4734b2c91
55 files changed
+4186 -496

No files matched your search

+242
View File
@@ -0,0 +1,242 @@
Feature: Editor screen — load, edit and save an article's body
As a verified RenewCMS owner
I want /article/:id/edit to load an article, let me edit its body, save it, and warn me
before I lose unsaved work
So that I can safely write and revise an article's content, with clear feedback while
saving, near the length limit, offline, or when my session expires mid-edit
Background:
Given /article/:id/edit is a verified route (meta.auth "verified") rendered as EditorPage
inside DashboardShell, per docs/design/routes.md
And EditorPage calls GET /v1/article/{id} once per navigation to load the article, per
docs/design/screens/editor.md and features/public_article_read.feature
And the header shows breadcrumb "Articles › {article.title}" linking "Articles" to
/article (mobile top bar title "Edit article" instead), an ArticleStatusBadge size "md"
reflecting article.is_online ("Draft" or "Online"), a SaveStatus span, a Save button,
and a PublishControl button, per docs/design/components.md
And the article's title is never editable after creation, per
features/article_title_rules.feature; only the body is edited here
And the body is edited in ArticleEditor (modelValue bound to the fetched HTML body); any
change moves useArticleDraft's state from "clean" to "dirty", and PublishControl's label
switches from "Publish" (or "Unpublish", if online) to "Save and publish" while dirty and
the article is a draft; clicking PublishControl itself is out of scope for this slice and
is specified in a follow-on slice (editor_publish_and_delete)
And Save is enabled only when useArticleDraft's state is "dirty", the request is not already
in flight, the app is not offline, and the body's HTML length is at most 10000 characters;
it is disabled in every other case
And Save (a click, or Ctrl/⌘ S) sends PATCH /v1/article/{id} with JSON {"body": "..."},
the same contract features/edit_article_body.feature already gives the API: 200 with the
updated article.Article, 400 for a body over 10000 characters, 404 for an id absent from
the repository, 401 with no valid session
And a successful save (200) sets useArticleDraft's state to "saved" and records the save
time; SaveStatus shows "Saved at {HH:MM}" for the first hour and "Saved {n} min ago" after
an hour has passed, per docs/design/components.md
And a 500 response or a request that never reaches the server sets useArticleDraft's state
to "error"; SaveStatus shows "Save failed" with an inline Retry button (UButton size="xs"
variant="outline"); the edited body is kept exactly as typed, both in the editor and for
the next Retry
And a 404 response to a save (the article was deleted elsewhere) does not use the "error"
SaveStatus; it shows a toast "That article no longer exists.", per docs/design/states.md's
HTTP-status-to-copy table
And a 401 response to a save is handled by useApi's global handling
(features/api_client_global_error_handling.feature): before the redirect, useArticleDraft
mirrors the current dirty body to sessionStorage keyed by the article's id, so it survives
the redirect to /login?redirect=/article/{id}/edit&reason=expired
And CharacterCounter shows "{len} / 10 000" below the editor, using the body's HTML string
length (formatting and images count); at 9000 characters or more (90%, "Near the limit")
it adds a warning icon and stays enabled; above 10000 ("Too long") it adds an icon and the
text "{over} characters over. Shorten the text or remove an image. Formatting counts too.",
and disables both Save and PublishControl until the length is 10000 or fewer again
And a Preview switch, when on, replaces the editable ArticleEditor and its toolbar with a
read-only rendering of the title and body in the content type scale; switching it off
restores the editable surface with the same content, unchanged
And when navigator.onLine is false, or the browser fires a window "offline" event,
useArticleDraft's state becomes "offline"; SaveStatus shows the wifi-off icon and reads
"Waiting for connection"; Save and PublishControl are disabled with a tooltip explaining
why, and DashboardShell's existing OfflineBanner is shown; when the connection returns (a
window "online" event, or navigator.onLine true again), Save and PublishControl re-enable
according to the underlying dirty/saved/error state, which is unaffected by having been
offline
And navigating away (the breadcrumb "Articles" link, a sidebar nav link, the browser back
button, or closing the tab) while useArticleDraft's state is "dirty" opens ConfirmDialog
(neutral tone) titled "Leave without saving?", text 'Your changes to "{article.title}"
haven't been saved.', cancel "Discard changes", confirm "Keep editing"; "Discard changes"
proceeds with the navigation, discarding the edit; "Keep editing" closes the dialog and
keeps the user on the editor with the edit intact; navigating away while "clean" or
"saved" never opens this dialog
And an id absent from the repository (GET resolves 404) replaces the entire editor with an
error state: title "This article doesn't exist.", and a single action "All articles"
navigating to /article; no breadcrumb-title, SaveStatus, Save, PublishControl or
ArticleEditor renders in this state
And skeleton placeholders (for the title, content and details) replace the editor's content
only after 150 ms of GET /v1/article/{id} being unresolved, so a fast response never
flashes a skeleton, per the Skeleton component spec
Scenario: Skeletons appear only after 150 ms, never for a fast response
Given the user navigates to /article/12/edit for the first time in this session
When GET /v1/article/12 has not resolved 150 ms after the navigation
Then skeleton placeholders replace the title, content and details areas
When GET /v1/article/12 then resolves 200 with a draft article titled "How we price a
bathroom renovation"
Then the skeletons are replaced by the Saved state's header, editor and details
Given a second, separate navigation to /article/12/edit
When GET /v1/article/12 resolves in under 150 ms
Then no skeleton is ever shown, and the Saved state's content renders directly
Scenario Outline: Saved — a freshly loaded article, draft or online
Given GET /v1/article/12 resolves 200 with an article titled "How we price a bathroom
renovation", is_online <is_online>, body length 4318, updated_at "Today, 11:02"
When the user navigates to /article/12/edit
Then the breadcrumb reads "Articles › How we price a bathroom renovation"
And the ArticleStatusBadge reads "<badge>"
And SaveStatus reads "Saved at 11:02"
And the Save button is disabled
And PublishControl reads "<publish_label>"
And the character counter reads "4 318 / 10 000"
Examples:
| is_online | badge | publish_label |
| false | Draft | Publish |
| true | Online | Unpublish |
Scenario: Unsaved changes — any edit marks the article dirty
Given the user is on /article/12/edit in the Saved state, article "How we price a bathroom
renovation", draft
When the user types into the body
Then SaveStatus reads "Unsaved changes"
And the Save button becomes enabled
And PublishControl now reads "Save and publish"
Scenario: Saving — Save sends the PATCH and disables itself while in flight
Given the user is on /article/12/edit with unsaved changes to the body
When the user activates Save
Then PATCH /v1/article/12 is sent with JSON {"body": "<the edited body>"}
And SaveStatus reads "Saving…"
And the Save button is disabled
Scenario: Ctrl/⌘ S saves the same way as clicking Save
Given the user is on /article/12/edit with unsaved changes to the body
When the user presses Ctrl+S (or ⌘+S)
Then PATCH /v1/article/12 is sent with JSON {"body": "<the edited body>"}
And SaveStatus reads "Saving…"
Scenario: A successful save returns to the Saved state with the new time
Given the user is on /article/12/edit in the Saving state
When PATCH /v1/article/12 resolves 200 with the updated article
Then SaveStatus reads "Saved at {current time}"
And the Save button is disabled again
Scenario: SaveStatus switches to a relative time after an hour
Given the user is on /article/12/edit and PATCH /v1/article/12 resolved 200 at 11:02
When more than an hour has passed with no further edit or save
Then SaveStatus reads "Saved {n} min ago" instead of "Saved at 11:02"
Scenario Outline: Save failed — a 500 or a network failure keeps the changes and offers Retry
Given the user is on /article/12/edit with unsaved changes to the body "Revised opening
paragraph."
When the user activates Save
And PATCH /v1/article/12 <failure>
Then SaveStatus reads "Save failed" with a Retry button
And the editor still shows the body "Revised opening paragraph."
And no navigation occurs
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: Retry resends the same body and can recover
Given the user is on /article/12/edit in the Save failed state, with unsaved body "Revised
opening paragraph." and PATCH /v1/article/12 having failed with a 500
When the user activates Retry
Then PATCH /v1/article/12 is sent again with JSON {"body": "Revised opening paragraph."}
When it resolves 200 with the updated article
Then SaveStatus reads "Saved at {current time}"
Scenario: A save against an article deleted elsewhere shows a toast, not the error SaveStatus
Given the user is on /article/12/edit with unsaved changes to the body
When the user activates Save
And PATCH /v1/article/12 resolves 404
Then a toast reads "That article no longer exists."
And SaveStatus does not read "Save failed"
Scenario: Near the limit — the counter warns at 9000 characters without blocking Save
Given the user is on /article/12/edit with unsaved changes making the body's HTML length
9240 characters
Then the character counter reads "9 240 / 10 000" with a warning icon
And the Save button remains enabled
Scenario: Too long — over 10000 characters disables Save and Publish until shortened
Given the user is on /article/12/edit with unsaved changes making the body's HTML length
10214 characters
Then the character counter reads "10 214 / 10 000" with the text "214 characters over.
Shorten the text or remove an image. Formatting counts too."
And the Save button is disabled
And PublishControl is disabled
When the user shortens the body to 10000 characters or fewer
Then the character counter no longer shows the over-limit text
And the Save button is enabled again
Scenario: Preview shows the rendered title and body without the editing surface
Given the user is on /article/12/edit, article titled "How we price a bathroom renovation"
with some body content
When the user switches Preview on
Then the editing toolbar and the editable surface are replaced by a read-only rendering of
the title and body in the content type scale
When the user switches Preview off
Then the editable ArticleEditor and toolbar return, with the same body content unchanged
Scenario: Offline blocks Save and Publish, and recovers when the connection returns
Given the user is on /article/12/edit with unsaved changes to the body
When navigator.onLine becomes false
Then SaveStatus shows the wifi-off icon and reads "Waiting for connection"
And the Save button and PublishControl are disabled with a tooltip explaining why
When navigator.onLine becomes true again
Then the Save button re-enables, reflecting the still-unsaved change
And SaveStatus reads "Unsaved changes" again
Scenario: Leaving with unsaved changes prompts, and "Discard changes" proceeds
Given the user is on /article/12/edit with unsaved changes to the body
When the user activates the "Articles" breadcrumb link
Then ConfirmDialog opens, titled "Leave without saving?", text 'Your changes to "How we
price a bathroom renovation" haven't been saved.', cancel "Discard changes", confirm
"Keep editing"
When the user activates "Discard changes"
Then the app navigates to /article
And the edited body is not saved
Scenario: Leaving with unsaved changes, "Keep editing" stays on the page
Given the user is on /article/12/edit with unsaved changes to the body, and the "Leave
without saving?" dialog is open
When the user activates "Keep editing"
Then the dialog closes
And the user remains on /article/12/edit with the unsaved body intact
Scenario: Leaving a clean article never prompts
Given the user is on /article/12/edit in the Saved state, with no unsaved changes
When the user activates the "Articles" breadcrumb link
Then the app navigates to /article directly, with no ConfirmDialog
Scenario: Session expired mid-save keeps the draft and restores it after logging back in
Given the user is on /article/12/edit with unsaved changes to the body "Paragraph added
just before the session expired."
When the user activates Save
And PATCH /v1/article/12 resolves 401
Then the body "Paragraph added just before the session expired." is mirrored to
sessionStorage keyed by article id 12
And the app navigates to /login?redirect=/article/12/edit&reason=expired
When the user logs in again and is returned to /article/12/edit
And GET /v1/article/12 resolves 200 with the last-saved body (without that paragraph)
Then the editor shows the body "Paragraph added just before the session expired." restored
from sessionStorage, not the last-saved body from the GET
And SaveStatus reads "Unsaved changes"
Scenario: An unknown or deleted article id replaces the editor with an error state
Given GET /v1/article/9999 resolves 404
When the user navigates to /article/9999/edit
Then the editor shows a single error state: title "This article doesn't exist." and one
action "All articles"
And no breadcrumb-title, SaveStatus, Save, PublishControl or ArticleEditor renders
When the user activates "All articles"
Then the app navigates to /article
+129
View File
@@ -0,0 +1,129 @@
# QA: Editor screen — load, edit and save (core)
## Implementation note for coder (informational, not a QA assertion)
`docs/design/components.md`'s `ErrorView` only documents two fixed copies (`kind: 'not-found'` →
"Page not found" / Home, and `kind: 'error'` → the generic retry copy). The editor's own 404-on-
load state needs different copy ("This article doesn't exist." / "All articles" → /article),
per `docs/design/states.md`'s HTTP-status-to-copy table. Either widen `ErrorView` with an optional
title/description/action override, or add a third `kind`; this is a same-layer prop widening, not
a new component, so it doesn't need its own slice.
Known limitation, not a bug to silently fix: per `features/public_article_read.feature`,
`GET /v1/article/{id}` falls back to public online-only visibility on a malformed/expired jwt
(no 401). So if a session is already stale when the owner opens an *offline* article, the GET
answers 404 exactly like a truly deleted article — the editor cannot tell the two apart from this
response alone, and correctly shows the "This article doesn't exist." state.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` for `GET /v1/article/{id}` and `PATCH /v1/article/{id}` per case; no
real backend, no httptest server. Mount EditorPage inside DashboardShell with a resolved verified
session (username "marta") and a test router at `/article/12/edit`. Use fake timers for the
150 ms skeleton delay and the relative-time SaveStatus format. Use `Object.defineProperty`/mocked
`navigator.onLine` and dispatched `window` `online`/`offline` events for the offline scenarios.
Fixture article (reused across cases unless a scenario overrides a field): id 12, title
"How we price a bathroom renovation", is_online false, body length 4318, created_at
"2026-03-12T09:14:00Z", updated_at "2026-09-26T11:02:00Z" (fixed `now`
"2026-09-26T11:02:00Z" unless a scenario advances it).
1. **Skeleton only after 150 ms** — mock `GET /v1/article/12` unresolved; assert no skeleton
before 150 ms; advance past it; assert skeleton placeholders for title/content/details;
resolve 200; assert the Saved state renders. Separately, resolve under 150 ms; assert no
skeleton ever renders.
2. **Saved state, draft and online** — Examples-driven on `is_online`: mock `GET
/v1/article/12` resolving 200 with the fixture at each `is_online` value; assert the
breadcrumb, ArticleStatusBadge ("Draft"/"Online"), SaveStatus "Saved at 11:02", Save
disabled, PublishControl ("Publish"/"Unpublish"), and character counter "4 318 / 10 000".
3. **Unsaved changes** — from the Saved state, type into the body; assert SaveStatus "Unsaved
changes", Save enabled, PublishControl now "Save and publish".
4. **Saving via click and via Ctrl/⌘ S** — from Unsaved changes, (a) click Save, (b) press
Ctrl+S; in each, assert `PATCH /v1/article/12` is called with the edited body, SaveStatus
"Saving…", Save disabled while unresolved.
5. **Successful save** — from Saving, resolve `PATCH` 200 with the updated article; assert
SaveStatus "Saved at {time}" and Save disabled again.
6. **Relative save time after an hour** — after a 200 save at 11:02, advance the fake clock past
one hour with no further edit; assert SaveStatus switches to "Saved {n} min ago".
7. **Save failed, Examples-driven** — (a) `PATCH` resolves 500, (b) `PATCH` rejects (network);
in each, assert SaveStatus "Save failed" with a visible Retry button, and the editor still
shows the typed body.
8. **Retry recovers** — from Save failed (500), click Retry; assert `PATCH` is called again with
the same body; resolve 200; assert SaveStatus "Saved at {time}".
9. **404 on save shows a toast, not the error SaveStatus** — from Unsaved changes, save; resolve
`PATCH` 404; assert a toast "That article no longer exists." appears and SaveStatus never
reads "Save failed".
10. **Near the limit** — set the body's HTML length to 9240 (via a fixture string of that
length); assert counter "9 240 / 10 000" with a warning icon and Save still enabled.
11. **Too long** — set the body's HTML length to 10214; assert counter "10 214 / 10 000" with
"214 characters over. Shorten the text or remove an image. Formatting counts too.", Save
disabled, PublishControl disabled; shorten to 10000; assert both re-enable and the over-limit
text disappears.
12. **Preview toggle** — from a loaded article with body content, switch Preview on; assert the
toolbar and editable surface are gone and a read-only rendering of title+body appears;
switch off; assert the editable surface returns with the same content.
13. **Offline blocks and recovers** — from Unsaved changes, set `navigator.onLine` false and
dispatch `window` `offline`; assert SaveStatus shows the wifi-off icon and reads "Waiting
for connection", Save and PublishControl disabled with a tooltip; set `navigator.onLine`
true and dispatch `online`; assert Save re-enables and SaveStatus reads "Unsaved changes"
again.
14. **Leave guard: Discard changes proceeds** — from Unsaved changes, click the "Articles"
breadcrumb link; assert ConfirmDialog opens with the exact title/text/button labels; click
"Discard changes"; assert the router navigates to `/article` and no further `PATCH` fires.
15. **Leave guard: Keep editing stays** — same setup; click "Keep editing"; assert the dialog
closes and the router does not navigate, and the unsaved body is still shown.
16. **No leave guard when clean** — from the Saved state, click the "Articles" breadcrumb link;
assert the router navigates to `/article` with no ConfirmDialog ever rendered.
17. **Session expiry mid-save mirrors and restores the draft** — from Unsaved changes with body
"Paragraph added just before the session expired.", save; resolve `PATCH` 401; assert
`sessionStorage` holds that body keyed by article id 12, and the router navigates to
`/login?redirect=/article/12/edit&reason=expired`. Then, simulating the return trip, mount
EditorPage again at `/article/12/edit` with `GET /v1/article/12` resolving 200 with a body
that lacks that paragraph; assert the rendered body is the restored sessionStorage draft
(with the paragraph) and SaveStatus reads "Unsaved changes".
18. **Not found on load** — mock `GET /v1/article/9999` resolving 404; mount at
`/article/9999/edit`; assert only the error state renders (title "This article doesn't
exist.", action "All articles"), with no breadcrumb-title/SaveStatus/Save/PublishControl/
ArticleEditor; click "All articles"; assert the router navigates to `/article`.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/screens/editor.md` and `docs/design/source/RenewCMS Screens
Back-office.dc.html`, group "Editor". Run `task visual:check -- editor` for each state below and
compare against the board frame; add each state's mock setup to `web/visual/states.ts` first,
reusing the sample data below and a fixed `now` of "2026-09-26T11:02:00Z" (updated_at "Today,
11:02"; created_at "12 Mar 2026, 09:14"; id 12; title "How we price a bathroom renovation";
3 images).
- [ ] Loading: skeletons for title, content and details, shaped like the real layout.
- [ ] Saved: breadcrumb, "Draft" badge, "Saved at 11:02", disabled Save, "Publish", counter
"4 318 / 10 000", details panel (Created/Updated/ID/Images/Public API URL).
- [ ] Unsaved changes: "Unsaved changes" SaveStatus, enabled Save, "Save and publish".
- [ ] Saving: "Saving…" SaveStatus, disabled Save.
- [ ] Save failed: "Save failed" with Retry, per the SaveStatus error spec.
- [ ] Near the limit: counter "9 240 / 10 000" with the warning icon/style.
- [ ] Too long: counter "10 214 / 10 000" with the over-limit text and disabled Save/Publish.
- [ ] Preview: read-only rendered title and body in the content scale, toolbar hidden.
- [ ] Offline: SaveStatus shows the wifi-off icon and reads "Waiting for connection", disabled
Save/Publish, OfflineBanner visible.
- [ ] Not found (404): the standalone error state, no editor chrome.
- [ ] Mobile (all of the above at 390×844): sticky bottom bar for Save/PublishControl, details
panel as a slideover, single "Edit article" mobile top-bar title instead of the breadcrumb.
- [ ] Keyboard/focus: Tab reaches the breadcrumb, Save, Preview switch and PublishControl in a
sensible order; Ctrl/⌘ S saves from anywhere in the editor surface; the leave-guard
ConfirmDialog traps focus starting on "Discard changes" per ConfirmDialog's a11y spec, and
Esc behaves like dismissing (equivalent to "Keep editing"); the character-counter live
region announces only when crossing the warn/over thresholds, not on every keystroke.
- [ ] Motion: modal open/close and skeleton pulse follow the standard 240 ms / 1.6 s timings and
stop under reduced motion.
## Manual verification
- **Real GET/PATCH round trip against a running server** — Vitest only mocks `useApi`; confirm
the real 200/404/401/500 responses and the real `sessionStorage` restoration across an actual
session expiry (`JWT_SECRET`-signed cookie timing out) against `task dev` with a live SQLite
file, since automated tests never exercise the real Go handler, `UpdateArticleUseCase`, or a
real cookie's `exp` claim.
- **Real `beforeunload` prompt** — Vitest can assert `onBeforeRouteLeave`'s ConfirmDialog, but a
real browser tab-close prompt (native `beforeunload`) needs manual confirmation, since jsdom
doesn't model it.