9.2 KiB
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).
- Skeleton only after 150 ms — mock
GET /v1/article/12unresolved; 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. - Saved state, draft and online — Examples-driven on
is_online: mockGET /v1/article/12resolving 200 with the fixture at eachis_onlinevalue; assert the breadcrumb, ArticleStatusBadge ("Draft"/"Online"), SaveStatus "Saved at 11:02", Save disabled, PublishControl ("Publish"/"Unpublish"), and the neutral count ending "4,318 characters". - Unsaved changes — from the Saved state, type into the body; assert SaveStatus "Unsaved changes", Save enabled, PublishControl now "Save and publish".
- Saving via click and via Ctrl/⌘ S — from Unsaved changes, (a) click Save, (b) press
Ctrl+S; in each, assert
PATCH /v1/article/12is called with the edited body, SaveStatus "Saving…", Save disabled while unresolved. - Successful save — from Saving, resolve
PATCH200 with the updated article; assert SaveStatus "Saved at {time}" and Save disabled again. - 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".
- Save failed, Examples-driven — (a)
PATCHresolves 500, (b)PATCHrejects (network); in each, assert SaveStatus "Save failed" with a visible Retry button, and the editor still shows the typed body. - Retry recovers — from Save failed (500), click Retry; assert
PATCHis called again with the same body; resolve 200; assert SaveStatus "Saved at {time}". - 404 on save shows a toast, not the error SaveStatus — from Unsaved changes, save; resolve
PATCH404; assert a toast "That article no longer exists." appears and SaveStatus never reads "Save failed". - Very long body — set the body's HTML length to 120000 (via a fixture string of that length); assert the count "19,845 words · 120,000 characters" with no warning styling, Save and PublishControl enabled.
- No limit states — assert no "Near the limit" or "Too long" text is ever rendered.
- 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.
- Offline blocks and recovers — from Unsaved changes, set
navigator.onLinefalse and dispatchwindowoffline; assert SaveStatus shows the wifi-off icon and reads "Waiting for connection", Save and PublishControl disabled with a tooltip; setnavigator.onLinetrue and dispatchonline; assert Save re-enables and SaveStatus reads "Unsaved changes" again. - 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
/articleand no furtherPATCHfires. - 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.
- No leave guard when clean — from the Saved state, click the "Articles" breadcrumb link;
assert the router navigates to
/articlewith no ConfirmDialog ever rendered. - Session expiry mid-save mirrors and restores the draft — from Unsaved changes with body
"Paragraph added just before the session expired.", save; resolve
PATCH401; assertsessionStorageholds 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/editwithGET /v1/article/12resolving 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". - Not found on load — mock
GET /v1/article/9999resolving 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 "N words · 4,318 characters", 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.
- Very long article: count "19,845 words · 120,000 characters", neutral, Save/Publish enabled.
- 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 realsessionStoragerestoration across an actual session expiry (JWT_SECRET-signed cookie timing out) againsttask devwith a live SQLite file, since automated tests never exercise the real Go handler,UpdateArticleUseCase, or a real cookie'sexpclaim. - Real
beforeunloadprompt — Vitest can assertonBeforeRouteLeave's ConfirmDialog, but a real browser tab-close prompt (nativebeforeunload) needs manual confirmation, since jsdom doesn't model it.