233 lines
14 KiB
Gherkin
233 lines
14 KiB
Gherkin
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, and the app is not offline, whatever the body's length; 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 (any body length, including empty), 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 EditorStatusBar shows a neutral "{words} words · {characters} characters" count with
|
||
en-US thousands separators (e.g. "1,204 words · 7,318 characters", "0 words · 0 characters"
|
||
for an empty body); characters is the body's HTML string length, words are the
|
||
whitespace-separated tokens of its text content; the count has no limit, warning or error
|
||
styling, per features/article_body_unlimited.feature
|
||
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 count reads "{words} words · 4,318 characters"
|
||
|
||
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: A very long body keeps a neutral count and leaves Save and Publish enabled
|
||
Given the user is on /article/12/edit with unsaved changes making the body's HTML length
|
||
120000 characters
|
||
Then the count reads "19,845 words · 120,000 characters" with no warning styling
|
||
And the Save button is enabled
|
||
And PublishControl is enabled
|
||
|
||
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
|