diff --git a/features/editor_core.feature b/features/editor_core.feature index 00ae7eb..ff7e65d 100644 --- a/features/editor_core.feature +++ b/features/editor_core.feature @@ -15,8 +15,10 @@ Feature: Editor screen — load, edit and save an article's body /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 title is an editable field labelled "Title" ("Title (French)" when the site has more + than one enabled language), per features/localization_editor_navigation.feature, which + supersedes features/article_title_rules.feature's "never editable"; the body is edited in + ArticleEditor 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 @@ -32,17 +34,21 @@ Feature: Editor screen — load, edit and save an article's body 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 the Save button reads "Save" for every caller allowed to edit; the "Save draft" label of + callers who cannot publish no longer exists 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 + to "error"; SaveStatus shows "Save failed" and the header's primary button reads "Try saving + again", per features/localization_editor_navigation.feature (the inline Retry button of + SaveStatus is no longer rendered in the editor); 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 + mirrors the current dirty title, body and search fields to sessionStorage keyed by the user + id and the article id, with the time of the mirror, 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 @@ -65,10 +71,11 @@ Feature: Editor screen — load, edit and save an article's body 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 an id absent from the repository (GET resolves 404) replaces the entire editor with the + Version unavailable state: title "This version isn't available", text "It may have been + deleted, or you may not have access.", and a single action "Back to Articles" navigating + to /article; no header, 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 @@ -132,12 +139,12 @@ Feature: Editor screen — load, edit and save an article's body 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 + Scenario Outline: Save failed — a 500 or a network failure keeps the changes and offers Try saving again 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 - Then SaveStatus reads "Save failed" with a Retry button + Then SaveStatus reads "Save failed" and the primary button reads "Try saving again" And the editor still shows the body "Revised opening paragraph." And no navigation occurs @@ -149,7 +156,7 @@ Feature: Editor screen — load, edit and save an article's body 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 + When the user activates "Try saving again" 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}" @@ -214,7 +221,7 @@ Feature: Editor screen — load, edit and save an article's body 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 + sessionStorage keyed by user id and 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) @@ -225,8 +232,8 @@ Feature: Editor screen — load, edit and save an article's body 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 editor shows a single Version unavailable state: title "This version isn't + available" and one action "Back to Articles" + And no header, SaveStatus, Save, PublishControl or ArticleEditor renders + When the user activates "Back to Articles" Then the app navigates to /article diff --git a/features/editor_core.qa.md b/features/editor_core.qa.md index b79f69c..3774c12 100644 --- a/features/editor_core.qa.md +++ b/features/editor_core.qa.md @@ -4,16 +4,15 @@ `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. +load state shows the Version unavailable copy ("This version isn't available" / "Back to +Articles" → /article), per `features/localization_editor_navigation.feature`, which supersedes the +original "This article doesn't exist." copy; it is rendered by `EditorVersionGone`. 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. +response alone, and correctly shows the "This version isn't available" state. ## Automated (Vitest + Vue Test Utils, `task test:web`) @@ -46,9 +45,9 @@ Fixture article (reused across cases unless a scenario overrides a field): id 12 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 + in each, assert SaveStatus "Save failed" with the primary button "Try saving again", and the editor still shows the typed body. -8. **Retry recovers** — from Save failed (500), click Retry; assert `PATCH` is called again with +8. **Retry recovers** — from Save failed (500), click "Try saving again"; 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 @@ -74,15 +73,17 @@ Fixture article (reused across cases unless a scenario overrides a field): id 12 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 + `sessionStorage` holds that body keyed by user id and article id 12 (key + `renewcms:article-draft:{userId}:12`, a JSON object with title, body, search fields and the + mirror time), 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`. + `/article/9999/edit`; assert only the Version unavailable state renders (title "This version + isn't available", action "Back to Articles"), with no header/SaveStatus/Save/PublishControl/ + ArticleEditor; click "Back to Articles"; assert the router navigates to `/article`. ## Visual (manual verification — desktop and mobile, light and dark) @@ -98,7 +99,7 @@ reusing the sample data below and a fixed `now` of "2026-09-26T11:02:00Z" (updat "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. +- [ ] Save failed: "Save failed" with the "Try saving again" primary, 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 diff --git a/features/localization_editor_navigation.feature b/features/localization_editor_navigation.feature new file mode 100644 index 0000000..638612f --- /dev/null +++ b/features/localization_editor_navigation.feature @@ -0,0 +1,362 @@ +Feature: Editor language navigation, title, header actions and permission states + The article editor at /article/:id/edit opens one language version of an article. A language menu switches between the readable versions, the title is an editable field, the header shows one primary action chosen from the version's capabilities, and every control renders from capabilities[] on GET /v1/article/{id}. Unsaved work survives a session expiry and is restored for the same user and article only. SPA only: the backend contract of features/article_story_lifecycle.feature, features/article_edit_revision.feature and features/site_languages.feature is unchanged. + + Background: + Given /article/:id/edit is a verified route rendered by EditorView inside DashboardShell, per docs/design/routes.md + And the visual spec is docs/design/screens/localization-editor.md (groups L5, L5b, L8) and docs/design/screens/localization-conflicts-moves.md (loc.l11.session, loc.l11.restored), with the copy drawn in docs/design/source/LocaleFrame.dc.html + And error responses are plain text from http.Error, never JSON, and the SPA never shows an HTTP status name, a revision number or an id + And GET /v1/article/{id} carries lang, story_id, capabilities[] and translations[] as in features/article_story_lifecycle.feature + And capabilities[] holds any of "read", "edit", "publish", "delete", "create_translation" and is the only input for showing or enabling an action + And translations[] lists readable versions only, default language first then by tag, each with id, lang, title, is_online, language_enabled and capabilities, and includes the version being read + And GET /v1/site/{key}/languages (readable by any signed-in user) gives the site's enabled languages and the default, and supplies the languages that have no version yet + And the UI says "article", never "story", and names the language in action copy only when the site has more than one enabled language + And site "journal" has enabled languages en (default), fr, de, ar and disabled language es + And story 5 holds article 10 (en, online, "Renovation costs"), article 12 (fr, offline, "Couts de renovation"), article 11 (de, offline, not readable to the caller) and article 13 (ar, online, "Takalif") + And "owner" holds capabilities ["read","edit","publish","delete","create_translation"] on every version + And PATCH /v1/article/{id} is sent without "if_revision" in this slice (last write wins, as today); the revision conflict dialog belongs to localization_translation_flows + + Rule: The language menu + + Scenario: The menu lists readable versions in API order with their state + Given the owner is on /article/12/edit + When the owner activates the language control named "Language: French. Switch version" + Then a menu opens, not a listbox, with the rows English, Arabic and French and no German version row + And German appears only as an "Add translation" row, because the owner holds create_translation + And each row shows its language name, its code and a publication badge ("Published" or "Draft") + And the row for French is marked current + And the order is English (default), Arabic, French, following translations[] + + Scenario Outline: Row tags and absent rows follow each sibling's capabilities + Given the caller reads article 12 and the sibling has capabilities + When the language menu opens + Then + + Examples: + | sibling | caps | result | + | Arabic (13) | ["read"] | the Arabic row is tagged "Read only" and opens that version | + | German (11) | not in translations[] | no German version row, no count, no placeholder; for a caller without create_translation no German row at all, for a caller with it only an "Add translation" row | + | English (10) | ["read","edit"] | the English row has no "Read only" tag | + | French disabled | language_enabled false | the row stays listed and opens, marked as a disabled language | + + Scenario Outline: Missing languages offer Add translation only to a caller who can create + Given the site enables en, fr, de and ar and article 20 exists only in en + And the caller's capabilities on article 20 are + When the language menu opens + Then + + Examples: + | caps | rows | + | ["read","edit","create_translation"] | missing languages, including languages whose version exists but is unreadable to the caller, appear as rows with an "Add translation" action | + | ["read","edit"] | the missing languages appear nowhere and nothing is offered | + + Scenario Outline: Menu size + Given the site enables languages and the caller reads versions + When the language menu opens + Then + + Examples: + | count | readable | layout | + | 4 | 4 | all four rows are visible without a filter | + | 2 | 2 | the two rows are visible without a filter | + | many | many | the list scrolls inside the viewport and a filter field narrows it by language name | + + Scenario: The menu is keyboard operable + Given the language menu is closed with focus on its control + When the user presses Enter or ArrowDown + Then the menu opens with focus on the current row + When the user presses ArrowDown and Enter + Then the next row is chosen + When the menu is open and the user presses Escape + Then it closes and focus returns to the control + + Scenario: A single-language site shows no language control + Given site "archive" enables one language and article 30 belongs to it + When the owner opens /article/30/edit + Then no language control, language badge or language name appears in the header or the action labels + And the primary action reads "Publish", not "Publish French" + + Rule: Switching versions opens another article id + + Scenario: Switching from a clean editor + Given the owner is on /article/12/edit with no unsaved changes + When the owner chooses English in the language menu + Then GET /v1/article/10 is requested before the route changes + And on 200 the route becomes /article/10/edit, the title shows "Renovation costs" and the control reads English + And a polite announcement reads "Editing English version" + + Scenario: A slow switch shows a loading state + Given GET /v1/article/10 has not resolved 150 ms after the choice + Then the editor shows the Loading version state (loc.l5.loading) and the current version stays on screen until the response arrives + When GET /v1/article/10 resolves in under 150 ms + Then no loading state is ever shown + + Scenario: A failed switch keeps the current version + Given the owner is on /article/12/edit + When the owner chooses English and GET /v1/article/10 answers 500 or never reaches the server + Then a toast reads "Couldn't open the English version" with "You're still editing French. Try again." + And the route is still /article/12/edit and the title and unsaved state are untouched + + Scenario: The editor opened directly on an unavailable version + Given GET /v1/article/9999 answers 404 "Article not found" + When the owner navigates to /article/9999/edit + Then the editor shows only the Version unavailable state with the title "This version isn't available" and the text "It may have been deleted, or you may not have access." + And one action "Back to Articles" navigates to /article + And no header, SaveStatus, Save, primary action, language control or editor surface renders + + Scenario Outline: Choosing another version with unsaved changes asks first + Given the owner is on /article/12/edit with unsaved changes to the French title + When the owner chooses English in the language menu + Then a dialog titled "Leave without saving?" reads "Your changes to the French version aren't saved." + And it offers "Keep editing", "Discard and open English" and "Save and open English" in that order + When the owner activates + Then + + Examples: + | choice | outcome | + | Keep editing | the dialog closes, the route stays /article/12/edit, the unsaved title is intact, no request is sent | + | Discard and open English | the local French draft is removed, then the route becomes /article/10/edit and nothing is saved | + | Save and open English | PATCH /v1/article/12 is sent first and on 200 the route becomes /article/10/edit | + + Scenario: Save and open English stops when the save fails + Given the "Leave without saving?" dialog is open for the French version + When the owner activates "Save and open English" and PATCH /v1/article/12 answers 500 + Then the route stays /article/12/edit, the edits stay unsaved and the Save failed state of the header shows + And no request for article 10 is sent + + Rule: The title is an editable field + + Scenario: The title replaces the heading and the old tooltip + Given the owner opens /article/12/edit + Then the title is an input in heading size labelled "Title (French)" holding "Couts de renovation" + And the tooltip "The title can't be changed after creation." no longer exists anywhere + And on a single-language site the label is "Title" + + Scenario Outline: Title field states + Given the owner is on /article/12/edit + When + Then the field shows + And the helper text is + And Save is + + Examples: + | action | look | helper | save | + | the page loads, title untouched | neutral border | none | disabled | + | the field gets focus, nothing changed | primary outline | "Changing the title doesn't change the address." | disabled | + | the user types "Couts reels" | primary border, counter "11 / 200" | "Changing the title doesn't change the address." | enabled | + | the user changes it and focus leaves | neutral border and Unsaved changes | "Changing the title doesn't change the address." | enabled | + | the user clears the field | error border and icon, aria-invalid | "Enter a title" as an alert | blocked | + | the user types "ab" | error border, counter "2 / 200" | "Use at least 3 characters" | blocked | + | the user types 203 characters | error border, counter "203 / 200" | "Use 200 characters or fewer" | blocked | + + Scenario: Saving the title sends it with the other edits + Given the owner changed the title to "Couts reels" and the body + When the owner activates Save + Then PATCH /v1/article/12 is sent with the title, the body and any changed search fields in one request + And the field shows a spinner and "Saving title…" while in flight + And on 200 the header title, the breadcrumb and the language menu row all read "Couts reels" + And the slug is not touched and no slug field is sent + + Scenario: A failed title save keeps the text + Given the owner changed the title to "Couts reels" + When PATCH /v1/article/12 answers 500 + Then the field keeps "Couts reels" with an error border and icon + And the helper reads "Couldn't save the title. Your text is kept." with a "Try again" action + And the state is still unsaved + When the owner activates "Try again" + Then the same PATCH is sent again and focus returns to the field + + Scenario: The server rejects a title the client allowed + Given the owner types a title the server answers 400 with apperr.ArticleTitleErrorMessage + Then the field shows the invalid state with that plain-text message and the edit is kept + + Rule: One primary action in the header + + Scenario Outline: Header priority and action matrix + Given the version is and the caller's capabilities are + Then the filled primary button is + And the secondary buttons are + And + + Examples: + | state | caps | primary | secondary | note | + | clean draft | edit, publish | "Publish French" | Save (disabled) | loc.l5.default | + | clean draft | edit | none | Save (disabled) | the hint "Publishing needs the publish permission" shows | + | dirty draft | edit | "Save" | none | loc.l5.dirty; the hint "Saving keeps it as a draft." shows | + | dirty draft | edit, publish | "Save and publish French" | Save | loc.l5.dirtyPublish | + | clean online | edit, publish | none | "Unpublish French", Save (disabled) | the alert "This version is online. Saving updates the live page right away." shows | + | dirty online | edit, publish | "Save" | "Unpublish French" | the same online alert shows | + | saving | any | "Saving…" busy | none | aria-busy is set on the button | + | saved | edit, publish | "Publish French" | Save (disabled) | loc.l5.saved | + | save failed | edit | "Try saving again" | none | nothing was published | + | publish failed after save | edit, publish | "Retry publish" | Save (disabled) | edits show as saved | + | publish without edit | publish | "Publish French" | none | fields are read-only | + | read-only | read | none | none | the reference action would stay; it is release 2 and absent here | + + Scenario: At most one filled button + Given any combination of state and capabilities in the matrix + Then at most one button in the header is filled + + Scenario: Saving an online version is allowed without publish permission + Given the version is online and the caller holds ["read","edit"] + Then Save is available, no Publish or Unpublish button renders and the online alert shows + + Scenario Outline: Collapse order by width + Given the owner is on the clean online French version with edit and publish capabilities + When the viewport is px wide + Then + + Examples: + | width | layout | + | 1440 | title, language, publication badge, status badge, Unpublish French, Save and More actions are all visible with their labels | + | 1024 | Unpublish moves into the More actions menu; the status badge and the other badges keep their words | + | 390 | title, language, publication and dirty badges stay at the top; Save, the primary and an overflow button sit in a fixed bottom bar; Unpublish, Edit slug, Move and Delete go to the overflow sheet; every touch target is at least 44 px high | + + Scenario: The overflow menu follows capabilities + Given the owner opens More actions on the French version + Then the menu holds Edit slug, Move article and Delete items, and Unpublish when the width collapsed it + And on mobile it opens as a sheet + When the caller lacks the capability for an item + Then the item is shown disabled with the reason as visible text and aria-disabled + And Edit slug, Move article and Delete article are shown disabled with the reason "Not available yet." until the slice that wires them + + Scenario: Cannot delete the whole article (P6) + Given the caller can create and modify only their own versions and holds ["read","edit","publish","delete","create_translation"] on French but not on a sibling + When the caller opens More actions + Then "Delete article" and "Move article" are disabled with the reason "You can't delete this article." + And "Delete French version" is enabled + + Rule: Save and publish + + Scenario: Save and publish saves first, then publishes only that version + Given the owner is on the dirty French draft with edit and publish capabilities + When the owner activates "Save and publish French" + Then a dialog titled "Save and publish French?" reads "Your unsaved changes to the French version are saved first, then only the French version goes online. English and German aren't affected." + When the owner confirms + Then PATCH /v1/article/12 is sent first + And only after it answers 200 is PATCH /v1/article/12/publish sent + And no request touches articles 10, 11 or 13 + And a toast reads "French published" with "Other versions weren't changed." + + Scenario: A failed save publishes nothing + Given the owner confirmed "Save and publish French" + When PATCH /v1/article/12 answers 500 + Then PATCH /v1/article/12/publish is never sent + And a toast reads "Couldn't save French" with "Nothing was published. Your changes are kept. Try again." + And the banner reads "Couldn't save French" with "Your changes are kept here. Nothing was published. Try saving again." and the primary reads "Try saving again" + And the edits stay unsaved + + Scenario: A failed publish after a good save is not a failed save + Given PATCH /v1/article/12 answers 200 + When PATCH /v1/article/12/publish answers 500 + Then the banner reads "Saved as a draft. Publishing failed. Try publishing again." with "Your edits are saved. Only the publishing step failed." + And the state reads Saved, not Save failed, and the primary reads "Retry publish" + When the owner activates "Retry publish" + Then only PATCH /v1/article/12/publish is sent + + Scenario: Publishing is blocked in a disabled language + Given French is disabled on journal + When PATCH /v1/article/12/publish answers 422 "language_disabled" + Then a warning reads "French is disabled on this site. Re-enable it to publish." with "You can still edit and save this version. Ask an owner to re-enable French." + And Save keeps working + + Scenario Outline: Publishing a clean version + Given the owner is on the clean version + When the owner activates "