feat: editor language menu, title field, header actions, publish dialogs, read-only rendering and draft recovery
This commit is contained in:
1 parent
a671b81f80
commit
339e646eb8
85 files changed
+6369
-624
No files matched your search
@@ -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 <failure>
|
||||
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
|
||||
+13
-12
@@ -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
|
||||
|
||||
@@ -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 <sibling> has capabilities <caps>
|
||||
When the language menu opens
|
||||
Then <result>
|
||||
|
||||
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 <caps>
|
||||
When the language menu opens
|
||||
Then <rows>
|
||||
|
||||
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 <count> languages and the caller reads <readable> versions
|
||||
When the language menu opens
|
||||
Then <layout>
|
||||
|
||||
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 <choice>
|
||||
Then <outcome>
|
||||
|
||||
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 <action>
|
||||
Then the field shows <look>
|
||||
And the helper text is <helper>
|
||||
And Save is <save>
|
||||
|
||||
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 <state> and the caller's capabilities are <caps>
|
||||
Then the filled primary button is <primary>
|
||||
And the secondary buttons are <secondary>
|
||||
And <note>
|
||||
|
||||
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 <width> px wide
|
||||
Then <layout>
|
||||
|
||||
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> version
|
||||
When the owner activates "<button>"
|
||||
Then a dialog titled "<title>" reads "<text>"
|
||||
When the owner confirms
|
||||
Then <request> is sent and only that version changes
|
||||
|
||||
Examples:
|
||||
| version | button | title | text | request |
|
||||
| French draft | Publish French | Publish French? | Only the French version goes online. English and German aren't affected. | PATCH /v1/article/12/publish |
|
||||
| French online | Unpublish French | Unpublish French? | The French version goes back to draft and stops being public. English stays online. | PATCH /v1/article/12/unpublish |
|
||||
|
||||
Scenario: Unpublish never saves local edits
|
||||
Given the owner is on the dirty online French version
|
||||
When the owner activates "Unpublish French"
|
||||
Then the dialog titled "Unpublish French?" reads "The saved French version goes offline and becomes a draft. English stays online." and an alert "Your edits stay in the editor" explains "Your unsaved changes are kept here. Nothing is saved when you unpublish. Save them yourself when you are ready."
|
||||
When the owner confirms
|
||||
Then only PATCH /v1/article/12/unpublish is sent, with no PATCH /v1/article/12
|
||||
And the editor still holds the unsaved edits and the state is still unsaved
|
||||
|
||||
Scenario: Publish fails without a save
|
||||
When a clean publish answers 500
|
||||
Then a toast reads "Couldn't publish French" with "Nothing changed. Try again."
|
||||
|
||||
Rule: Permission-driven rendering
|
||||
|
||||
Scenario Outline: A version without edit renders read-only
|
||||
Given the caller's capabilities on article 12 are <caps>
|
||||
When the caller opens /article/12/edit
|
||||
Then <result>
|
||||
|
||||
Examples:
|
||||
| caps | result |
|
||||
| ["read"] | a banner reads "You can read this version. You can't edit it." with "The source reference and the versions you can read stay available."; the title and body are read-only (aria-readonly); the formatting toolbar is hidden; no Save, Publish, Unpublish or Delete renders; keyboard Ctrl/⌘ S does nothing |
|
||||
| ["read","publish"] | a notice reads "You can publish this version but not edit it" with "Fields are read-only. Publish stays available."; the title and body are read-only and the toolbar is hidden; "Publish French" (or Unpublish) is available |
|
||||
| ["read","edit"] | a notice reads "Publishing needs the publish permission" with "Saving keeps it as a draft."; Save shows and no Publish control renders |
|
||||
|
||||
Scenario: The read-only menu has no Add translation row
|
||||
Given the caller holds ["read"] on article 12 and cannot create on the site
|
||||
When the language menu opens
|
||||
Then no "Add translation" row is offered for any missing language
|
||||
|
||||
Scenario: Server checks still win
|
||||
Given the caller's capabilities say ["read","edit"] but PATCH /v1/article/12 answers 403 "missing_permission"
|
||||
Then a toast reports the failure, the edit is kept and the state is Save failed
|
||||
|
||||
Rule: Session expiry and local draft recovery
|
||||
|
||||
Scenario: A 401 on save mirrors the draft for this user and article
|
||||
Given user 7 is on /article/12/edit with an unsaved title "Couts reels", body and search fields
|
||||
When the owner activates Save and PATCH /v1/article/12 answers 401
|
||||
Then the title, the body and the search fields are stored in this browser under user 7 and article 12 with the time of the mirror
|
||||
And the app navigates to /login?redirect=/article/12/edit&reason=expired
|
||||
|
||||
Scenario: Restoring after login
|
||||
Given user 7 stored a draft for article 12 at 14:18
|
||||
When user 7 logs in again and the editor loads article 12 (GET answers 200 with older content)
|
||||
Then the editor shows the stored title, body and search fields, not the loaded ones
|
||||
And an info alert reads "Restored unsaved changes from 14:18" with "You logged in again. Your French title and body were kept in this browser."
|
||||
And the state is unsaved
|
||||
|
||||
Scenario: Another account never sees the draft
|
||||
Given user 7 stored a draft for article 12
|
||||
When user 8 opens /article/12/edit
|
||||
Then the loaded article is shown unchanged with no alert and user 7's draft is not read
|
||||
|
||||
Scenario: A draft is for one article
|
||||
Given user 7 stored a draft for article 12
|
||||
When user 7 opens /article/10/edit
|
||||
Then nothing is restored
|
||||
|
||||
Scenario Outline: When the local draft is deleted
|
||||
Given user 7 stored a draft for article 12
|
||||
When <event>
|
||||
Then the stored draft for user 7 and article 12 is removed
|
||||
|
||||
Examples:
|
||||
| event |
|
||||
| a save of article 12 answers 200 |
|
||||
| the owner activates "Discard and open English" or "Discard changes" |
|
||||
| user 7 logs out explicitly |
|
||||
| GET /v1/article/12 answers 404 |
|
||||
| GET /v1/article/12 returns capabilities without "edit" |
|
||||
|
||||
Scenario: A session expiry keeps the draft
|
||||
Given a 401 redirects to /login with reason=expired
|
||||
Then the stored draft is not removed by the redirect
|
||||
|
||||
Scenario: A stored draft grants nothing
|
||||
Given a stored draft exists for article 12 and the caller can no longer read it
|
||||
Then the editor shows the Version unavailable state and the draft is removed, never rendered
|
||||
|
||||
Rule: Existing editor behaviour that stays
|
||||
|
||||
Scenario: Skeleton, preview, offline and the leave guard are unchanged
|
||||
Then features/editor_core.feature keeps its skeleton after 150 ms, Preview, Waiting for connection, OfflineBanner and "Leave without saving?" (Discard changes / Keep editing) when navigating away, except where this feature replaces them
|
||||
|
||||
Scenario: Replaced behaviour
|
||||
Then "The title is never editable" in features/editor_core.feature is superseded by the title field above
|
||||
And the session draft key of features/editor_core.feature scenario "Session expired mid-save" is superseded by the user and article scoped key above
|
||||
And the 404-on-load copy "This article doesn't exist." is superseded by the Version unavailable copy above
|
||||
@@ -0,0 +1,91 @@
|
||||
# QA: Editor language navigation, title, header actions and permission states
|
||||
|
||||
Accepted together with features/localization_editor_navigation.feature. SPA only, nothing outside web/ changes.
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi`'s `request` per case (no backend). Mount EditorView in DashboardShell with a resolved verified session (user id 7, username "marta") and a test router at `/article/:id/edit`. Use fake timers for the 150 ms delays. Specs live in `web/src/views/__tests__/` and `web/src/composables/__tests__/`, reusing `editorMetaKit.ts`.
|
||||
|
||||
Fixture (journal, story 5; add `lang`, `story_id`, `capabilities`, `translations` to the article mock): article 10 en online "Renovation costs"; article 12 fr offline "Couts de renovation", updated_at "2026-09-26T11:02:00Z"; article 13 ar online "Takalif"; article 11 de is never in `translations`. `translations` order en, ar, fr. `GET /v1/site/journal/languages` mocked with en (default), fr, de, ar enabled and es disabled. Capability fixtures: full ["read","edit","publish","delete","create_translation"], ro ["read"], p2 ["read","edit"], p3 ["read","publish"], p6 (full on 12, no delete on a sibling). Error mocks reject with `ApiError` carrying a plain-text body.
|
||||
|
||||
Language menu
|
||||
1. **Menu rows**: open the control "Language: French. Switch version"; assert role menu, rows en, ar, fr in order, no German version row (German only as an Add translation row for create_translation), publication badges, current row marked (loc.l5.menu).
|
||||
2. **Two and many languages**: two-language site shows two rows, no filter (loc.l5.two); many-language site scrolls and the filter field narrows by name (loc.l5.many).
|
||||
3. **Sibling tags**: Examples on sibling capabilities: `["read"]` gives "Read only" tag (loc.perm.siblingReadOnly); absent entry gives no row, no count, no placeholder (loc.perm.siblingHidden); disabled language is listed and marked.
|
||||
4. **Add translation rows**: with create_translation the missing languages (including unreadable existing versions) show "Add translation" rows (loc.l5b.withAdd); without it nothing is offered (loc.l5b.noAdd); read-only menu has none (loc.l5b.roMenu).
|
||||
5. **Keyboard**: Enter/ArrowDown opens, arrows move, Enter chooses, Escape closes and refocuses the control.
|
||||
6. **Single-language site**: no language control, label "Title", primary reads "Publish".
|
||||
|
||||
Switching
|
||||
7. **Clean switch**: choose English; assert `GET /v1/article/10` before `router.push('/article/10/edit')`, then title "Renovation costs".
|
||||
8. **Loading**: GET unresolved past 150 ms shows Loading version and keeps the current version (loc.l5.loading); resolved faster shows nothing.
|
||||
9. **Switch failed**: GET 10 rejects with 500 and with a network error; toast "Couldn't open the English version" / "You're still editing French. Try again."; route and edits untouched (loc.l5.navFail).
|
||||
10. **Version unavailable**: GET 9999 answers 404; only the state with "This version isn't available" and "Back to Articles" renders (loc.l5.gone).
|
||||
11. **Unsaved switch dialog, Examples-driven**: Keep editing, Discard and open English, Save and open English; assert dialog copy and button order (loc.l5.unsaved), requests and route per choice, local draft removed on discard.
|
||||
12. **Save and open English fails**: PATCH 500; route unchanged, no GET for article 10, Save failed shown.
|
||||
|
||||
Title
|
||||
13. **Title field states, Examples-driven**: pristine, focus, editing (counter "11 / 200"), unsaved, empty ("Enter a title" as alert, aria-invalid, Save blocked), "ab" ("Use at least 3 characters"), 203 characters ("203 / 200", "Use 200 characters or fewer"); ids loc.l5.focus, titleEdit, titleUnsaved, invalid, titleShort, titleLong.
|
||||
14. **Title counts code points**: 200 repetitions of the 4-byte emoji is valid.
|
||||
15. **Save sends title with the other edits** in one PATCH, shows "Saving title…" (loc.l5.titleSaving), updates header, breadcrumb and menu row on 200, sends no slug.
|
||||
16. **Title save failed**: PATCH 500; text kept, "Couldn't save the title. Your text is kept.", "Try again" resends and refocuses the field (loc.l5.failed). A 400 shows the plain-text message in the invalid state.
|
||||
|
||||
Header and actions
|
||||
17. **Action matrix, Examples-driven** over every row of the feature table: assert the single filled button, the secondary buttons, the hints and the online alert; assert at most one filled button for every combination of state and capabilities (loc.l5.default, dirty, dirtyPublish, cleanOnline, onlineDirty, saving, saved).
|
||||
18. **Save failed**: banner "Couldn't save French", primary "Try saving again", nothing published (loc.l5.saveFail).
|
||||
19. **Collapse**: with the viewport mocked at 1440, 1024 and 390 assert where Unpublish, Edit slug, Move, Delete and the bottom bar render (loc.l5.ovf for the overflow menu and sheet). Touch targets at 390 have class or computed height of at least 44 px.
|
||||
20. **Overflow items follow capabilities**, disabled items carry `aria-disabled` and the reason text; P6 shows "You can't delete this article." for Delete article and Move, with "Delete French version" enabled (loc.perm.createNotAll). Edit slug, Move article and Delete article carry `aria-disabled` and the reason "Not available yet." until wired by a later slice; Delete French version stays enabled.
|
||||
|
||||
Save and publish
|
||||
21. **Save and publish order**: confirm dialog copy; assert PATCH 12 resolves before PATCH 12/publish is called; no request for 10, 11 or 13; toast "French published" (loc.l8.saveAndPublish, loc.l8.done).
|
||||
22. **Save fails**: publish never called; toast and banner copy (loc.l8.saveFailPublish).
|
||||
23. **Publish fails after save**: Saved state, banner "Saved as a draft. Publishing failed. Try publishing again.", "Retry publish" sends only publish (loc.l5.pubFail).
|
||||
24. **Language disabled**: publish 422 "language_disabled" shows the warning, Save still works (loc.lang.publishDisabled).
|
||||
25. **Clean publish and unpublish dialogs**, Examples-driven, plus failure toast (loc.l8.publish, unpublish, fail).
|
||||
26. **Unpublish with unsaved edits**: only `/unpublish` called, edits kept, still unsaved (loc.l8.unpublishDirty).
|
||||
|
||||
Permissions
|
||||
27. **Read-only, Examples-driven over caps**: ["read"] (loc.perm.readonly), ["read","publish"] (loc.perm.publishNoEdit), ["read","edit"] (loc.perm.editNoPublish): banner copy, `aria-readonly`, toolbar hidden or shown, which action buttons exist, Ctrl/⌘ S does nothing without edit.
|
||||
28. **Server 403 wins** over capabilities: Save failed with edits kept.
|
||||
|
||||
Recovery
|
||||
29. **401 mirrors the draft** keyed by user 7 and article 12 with title, body, search fields and the mirror time; router goes to `/login?redirect=/article/12/edit&reason=expired`.
|
||||
30. **Restore after login**: older server content, stored draft with time "14:18" shown instead; alert "Restored unsaved changes from 14:18"; unsaved state (loc.l11.restored).
|
||||
31. **Isolation**: user 8 sees no alert and no restored content; user 7 on article 10 sees none.
|
||||
32. **Cleanup, Examples-driven**: saved 200, discard, explicit logout, GET 404, GET without "edit"; the redirect after a 401 does not clear it.
|
||||
33. **Draft grants nothing**: stored draft plus 404 shows Version unavailable and removes the draft.
|
||||
|
||||
Regression
|
||||
34. Re-run `EditorView.spec.ts`, `EditorSeo*.spec.ts` and `useArticleDraft.spec.ts`; update the assertions that this feature supersedes (immutable title tooltip, draft key, 404 copy, "Save draft" label) and nothing else.
|
||||
|
||||
## Visual (`task visual:check`)
|
||||
|
||||
Reference: docs/design/screens/localization-editor.md and docs/design/screens/localization-conflicts-moves.md, boards in docs/design/source/. Add each state's setup to `web/visual/localizationStates.ts` first, reusing the board's Journal fixture data from `locale-fixtures.js` (titles, languages, counts, the 14:18 restore time). Every capture must be within 10 %, at the listed sizes, light and dark. No exceptions or threshold changes without the user's approval.
|
||||
|
||||
The checker fails a state with no setup, so run it state by state during the slice: `task visual:check -- localization-editor <state>`, and `task visual:check -- localization-conflicts-moves loc.l11.session`, `... loc.l11.restored`. The full-screen run of localization-editor only passes once localization_translation_flows adds the deletion states; this slice does not run it whole (see open question 9).
|
||||
|
||||
In scope, with sizes:
|
||||
- L5 (1440, 1024, 390): default, dirty, dirtyPublish, cleanOnline, onlineDirty, saveFail, pubFail.
|
||||
- L5 (1440, 390): saving, saved, ovf, menu, two, many, unsaved, loading, navFail, gone, focus, titleEdit, titleUnsaved, invalid, titleShort, titleLong, titleSaving, failed.
|
||||
- L5b (1440, 1024, 390): perm.readonly, perm.editNoPublish, perm.publishNoEdit.
|
||||
- L5b (1440, 390): l5b.roMenu, perm.siblingReadOnly, perm.siblingHidden, perm.createNotAll, l5b.noAdd, l5b.withAdd.
|
||||
- L8 (1440, 390): publish, publishDisabled, saveAndPublish, saveFailPublish, unpublish, unpublishDirty, done, fail.
|
||||
- L11 (1440, 390): session, restored.
|
||||
|
||||
Clock-sensitive: none expected. The board draws fixed times (Saved, 14:18); the setups freeze `now` as in features/editor_core.qa.md. The relative "Saved n min ago" form is not asserted visually. If a toast or SaveStatus depends on the real clock in a setup, list it as `skip: clock-sensitive`.
|
||||
|
||||
## Manual
|
||||
|
||||
- Focus order: title, language control, status, Unpublish, Save, primary, More actions; focus returns to the control on menu close, to the field on Try again, to the button after a save.
|
||||
- Screen reader announcements: "Editing French version", "Saving title", "Title saved", "French saved", "Saved as a draft. Publishing failed. Try publishing again."
|
||||
- Hover and pressed states of the language rows, Read only tag contrast, motion (no slide or fade under reduced motion; the spinner becomes a static icon).
|
||||
- Real round trip against `task dev`: switch versions, a real 401 after cookie expiry with draft restore, and logout clearing the draft (Vitest mocks `useApi` and `sessionStorage`).
|
||||
- Between 1024 and 1440 the header is specified, not drawn: resize by hand and check nothing overlaps.
|
||||
|
||||
## Known gaps and scope decisions
|
||||
|
||||
- loc.l11.session (the "Your session expired" modal) is not implemented: the existing redirect to /login?reason=expired stays. The visual checker records it as a known gap.
|
||||
- The Add translation row in the language menu renders from capabilities but does nothing until localization_translation_flows.
|
||||
- No if_revision is sent in this slice; the conflict dialog and the "Save over theirs" destructive cue belong to localization_translation_flows.
|
||||
- features/editor_core.feature and features/editor_core.qa.md are amended in this slice for the editable title, the user and article scoped draft key, the Version unavailable copy and the "Save draft" label.
|
||||
- The full-screen run of task visual:check for localization-editor passes only after localization_translation_flows; this slice checks state by state.
|
||||
Reference in new issue
Block a user