Files
RenewCMS/features/localization_translation_flows.feature
T

635 lines
51 KiB
Gherkin

Feature: Translation flows, revision conflicts, delete scopes, article moves and a target site for new articles
POST /v1/article/ accepts an optional target site, and the editor and the New article modal use it together with the existing endpoints: they create translations, protect saves with the revision they loaded, delete one version or the whole article with a named scope, and move an article to another site as a whole. The backend part is the Rule "A new article can name its site"; every other backend contract of features/article_translation_create.feature, features/article_edit_revision.feature, features/article_story_lifecycle.feature and features/site_languages.feature is unchanged. This feature completes the inert parts of features/localization_editor_navigation.feature (Add translation rows, Move article, Delete article, the missing if_revision).
Background:
Given the editor is /article/:id/edit rendered by EditorView, per docs/design/routes.md, and /article (and /article/new) open NewArticleModal over the list
And the visual spec is docs/design/screens/localization-new-article.md (L3, L4), docs/design/screens/localization-editor.md (L8 deletion states) and docs/design/screens/localization-conflicts-moves.md (L11, L11b), with the copy drawn in docs/design/source/LocaleFrame.dc.html
And error responses are plain text from http.Error, never JSON; ApiError.code carries the snake_case body and ApiError exposes the response headers X-Existing-Article-Id, X-Current-Revision, X-Missing-Languages and X-Conflicting-Articles
And the SPA never shows an HTTP status name, a revision number, an id or a snake_case code, and says "article", never "story"
And capabilities[] and translations[] on GET /v1/article/{id} remain the only input for showing or enabling an action, as in localization_editor_navigation
And a site is identified by its key (a string matching the sitekey validate tag), as in POST /v1/story/{story_id}/move {"site":"<key>"}, GET /v1/article/?site=<key> and GET /v1/site/{key}/languages
And GET /v1/site/ (readable by any verified user) supplies the sites with "key", "name" and "is_default"
And GET /v1/site/{key}/languages (readable by any signed-in user) supplies the enabled languages, the default and the language order of a site
And site "journal" (id 1, the default site) has enabled languages en (default), fr, de, ar and disabled language es; site "atelier" (id 2) enables en, fr, de; site "notes" (id 3) enables en; site "archive" (id 4) enables en only
And story 5 holds article 10 (en, online, "Renovation costs"), 12 (fr, offline, "Couts de renovation"), 11 (de, offline) and 13 (ar, online, "Takalif", the owner cannot edit it)
And "owner" reads all four versions of story 5 and holds ["read","edit","publish","delete","create_translation"] on 10, 11 and 12 and ["read"] on 13
And "restricted" holds create_translation on the site, reads 10, 12 and 13 and cannot read 11
And language names in dialogs follow the site's language order (default first), matching the board, not the order of translations[]
And GET /v1/article/{id} carries "revision"; EditorView keeps it as the base revision of the loaded version
Rule: A new article can name its site
Background:
Given the language rules of features/article_localization_storage.feature and the creation rules of features/article_translation_create.feature apply
And a "writer" is a verified user with content.create, content.update and content.read at scope all, and a "reader" holds content.read only
And content.create is not scoped to a site: a writer may create in every site
And the request field is "site", a site key, optional, validated by the sitekey tag through validator.Validate.Struct; the handler passes it in CreateArticleCommand and the use case resolves it through the site Repository (GetByKey)
Scenario Outline: The site decides where the new article is created
Given a valid jwt cookie for a writer
When the client sends POST /v1/article/ with <body>
Then the response status is 201 and the body is the created article JSON
And <effect>
Examples:
| body | effect |
| {"title":"Plain","body":"x"} | site_id is 1 (the default site) and lang is the site's starting language, exactly as before this change |
| {"title":"Plain","site":"journal"} | site_id is 1, lang is journal's starting language |
| {"title":"Neuf","lang":"fr","site":"atelier"} | site_id is 2, lang "fr", is_online false, a new story is created, created_by is the caller |
| {"title":"Plain","site":"atelier"} | site_id is 2 and lang is atelier's starting language, never journal's |
| {"title":"Plain","site":null} | same as omitting "site" |
Scenario Outline: Refused target sites
Given a valid jwt cookie for <caller>
When the client sends POST /v1/article/ with <body>
Then the response status is <status>
And the response body is <message>
And nothing is stored
And a refusal that reaches the use case (404, 409, 422, short title) records one failed "article.create" event with a plain reason
And a refusal before the use case (400 validation, 401, 403) records no event, as before
Examples:
| caller | body | status | message |
| writer | {"title":"Plain","site":"missing"} | 404 | site_not_found |
| writer | {"title":"Plain","site":"Bad Key!"} | 400 | invalid_site_key |
| writer | {"title":"Plain","site":""} | 400 | invalid_site_key |
| writer | {"title":"Plain","site":"atelier","lang":"es"} | 422 | language_not_enabled (atelier has no es row) |
| writer | {"title":"Plain","site":"notes","lang":"fr"} | 422 | language_not_enabled (notes enables en only) |
| writer | {"title":"Plain","site":"journal","lang":"es"} | 409 | language_unavailable (es is disabled on journal) |
| writer | {"title":"Plain","site":"atelier","lang":"pt"} | 422 | language_not_enabled |
| writer | {"title":"Plain","site":"atelier","lang":"xx"} | 422 | unsupported_language |
| writer | {"title":"Plain","site":"atelier","lang":"english"} | 422 | invalid_language |
| writer | {"title":"ab","site":"atelier"} | 400 | the validate tag error text |
| reader | {"title":"Plain","site":"atelier"} | 403 | missing_permission |
| anonymous | {"title":"Plain","site":"atelier"} | 401 | Unauthorized |
Scenario: A site whose languages are not set up
Given site "fresh" has no confirmed language rows
When a writer sends POST /v1/article/ with {"title":"Plain","site":"fresh","lang":"fr"}
Then the response status is 409 and the body is "setup_required"
And nothing is stored
Scenario: The language rules are those of the chosen site
Given fr is enabled on atelier and disabled on journal
When a writer sends POST /v1/article/ with {"title":"Neuf","lang":"fr","site":"atelier"}
Then the response status is 201
When the writer sends the same request with "site":"journal"
Then the response status is 409 and the body is "language_unavailable"
Scenario: The slug is free within the chosen site and language
Given site "journal" and site "atelier" both hold an fr article with slug "couts"
When a writer creates an fr article titled "Couts" on atelier
Then the slug is the next free suffixed slug in (site 2, fr), computed from atelier's articles only
When a writer creates an fr article titled "Couts" on a site with no clash
Then the slug is "couts"
And a clash in another site or another language never changes the slug
Scenario Outline: A site together with a translation is refused
Given a valid jwt cookie for a writer
When the client sends POST /v1/article/ with <body>
Then the response status is 400 and the body is apperr.BodyErrorMessage
And nothing is stored
Examples:
| body |
| {"title":"Couts","lang":"fr","story_id":5,"site":"atelier"} |
| {"title":"Couts","lang":"fr","story_id":5,"source_article_id":10,"copy":true,"site":"journal"} |
| {"title":"Couts","site":"atelier","source_article_id":10} |
| {"title":"Couts","site":"atelier","copy":true} |
Scenario: A translation keeps the site of its source
Given a valid jwt cookie for a writer
When the client sends POST /v1/article/10/translations with {"lang":"fr","title":"Couts","copy":true}
Then the response status is 201 and site_id is 1, the site of article 10
And POST /v1/article/{id}/translations has no "site" field and an unknown "site" key in its body is ignored as before
Scenario: Public and API shapes do not change
Given a writer created an article with "site":"atelier"
Then the response has exactly the fields it had before, with site_id 2
And the article is offline, GET /v1/article/{id} without a jwt cookie answers 404 "Article not found" and the public list of both sites is unchanged
And GET /v1/article/?site=atelier lists it for readers and GET /v1/article/?site=journal does not
Scenario: Activity records the site
Given a valid jwt cookie for a writer
When an article is created on atelier
Then one "article.create" event (articles category) targets the new article and its params hold the site key
And a refusal that reaches the use case records one failed event with a plain reason that names no hidden data
Scenario: Persistence needs no new query
Then the use case calls the existing site GetByKey, LockStartingLanguage(site id), localization Rows(site id), SlugTaken(site id, lang, slug) and Articles.Create
And no repository, model, mapper or migration changes, so the real-database runs (task test:mysql, test:postgres) are not required for this change
Rule: Add translation opens from the language menu, the URL and the article list
Scenario: The Add translation row of the language menu opens the dialog
Given the owner is on /article/12/edit and German has no version offered to the owner
When the owner activates the "Add translation" row for German
Then the route becomes /article/12/edit?dialog=add-translation&lang=de
And a modal dialog titled "Add a translation" opens with focus on its first field
And the language menu closes and the Back button closes the dialog
Scenario Outline: The URL opens the same dialog
Given the caller's capabilities on article 12 are <caps> and the URL is <url>
When the editor has loaded
Then <result>
Examples:
| caps | url | result |
| ["read","edit","create_translation"] | /article/12/edit?dialog=add-translation&lang=de | the dialog is open with Language set to German |
| ["read","edit","create_translation"] | /article/12/edit?dialog=add-translation | the dialog is open with Language empty and the primary disabled until a language is chosen |
| ["read","edit","create_translation"] | /article/12/edit?dialog=add-translation&lang=fr | the dialog is open with Language empty, because French is not offered (a version exists) |
| ["read","edit"] | /article/12/edit?dialog=add-translation&lang=de | no dialog opens and the dialog and lang parameters are removed from the URL without a message |
| ["read","edit","create_translation"] | /article/12/edit?dialog=add-translation&lang=es | the dialog is open with Language empty, because Spanish is disabled |
Scenario: The article list row menu leads to the dialog
Given the list row menu of a story shows "Add translation…" because the item holds create_translation and missing_langs is not empty
When the user activates it
Then the route becomes /article/{id}/edit?dialog=add-translation, plus &lang=<tag> when exactly one language is missing
And the editor loads before the dialog opens
Scenario: Offered languages are the enabled languages with no readable version
Given the owner is on /article/12/edit
Then the Language select lists German only, in the site's language order, because 10, 12 and 13 are readable and es is disabled
And a language whose version exists but is unreadable to the caller is listed too, because the client cannot tell it from a language with no version
Rule: The form
Scenario: Copy from source, as drawn
Given the dialog is open for German
Then it shows the description of the Copy choice: "Creates a German draft of "Couts de renovation". It isn't published." where the title is the open version's
And the fields are Language (select), Source version (select, "English · published"), "Title in German" (focused, with the help "Copying text is a starting point: it stays in English until you translate it."), and Starting content with "Start empty" and "Copy from English"
And "Copy from English" is chosen, with the text "Copies body, excerpt, search and social text, image alternative text and authors. Images are reused, files aren't duplicated. The version starts as Needs review."
And the footer holds "Cancel" and "Create German draft", in that order
And the Source version select lists the readable versions of translations[] as "<Language> · published" or "<Language> · draft"
And the default source is the default-language version when readable, otherwise the first readable version
Scenario: Start empty
Given the dialog is open
When the owner chooses "Start empty"
Then the text reads "Only the title. Freshness shows No source until you pick a tracked source."
And the Source version field and the copy note are no longer shown
And "Copying text is a starting point" is replaced by no help text
Scenario Outline: Title validation runs before any request
Given the dialog is open for German
When the owner types <title> and activates "Create German draft"
Then <result>
Examples:
| title | result |
| nothing | the field shows "Enter a title" as an alert and no request is sent |
| "ab" | the field shows "Use at least 3 characters" and no request is sent |
| 201 characters | the field shows "Use 200 characters or fewer" and no request is sent |
| 200 emoji (200 runes) | the request is sent |
Scenario Outline: Long content shows what is copied
Given the dialog is open with the Copy choice and the chosen source has <authors> authors and <images> images
Then <notes>
Examples:
| authors | images | notes |
| 1 | 0 | no Authors or Images note shows |
| 10 | 8 | an Authors note lists the first five names and "and 5 more will be copied. You can change them in the editor." and an Images note reads "Cover image and 7 inline images are reused. Alternative text is copied and stays in English until you translate it." |
And the body scrolls while the footer stays visible at 390 px
Rule: Creating the translation
Scenario: Copy from a chosen source
Given the owner chose German, source "French" and the title "Kosten"
When the owner activates "Create German draft"
Then POST /v1/article/12/translations is sent with {"lang":"de","title":"Kosten","copy":true,"source_article_id":12}
And the dialog shows the Creating state: all fields disabled, "Cancel" disabled, the primary reads "Creating…" and busy, and Escape and the backdrop do nothing
Scenario: Start empty sends no source
Given the owner chose "Start empty"
When the owner submits
Then the body is {"lang":"de","title":"Kosten","copy":false} with no source_article_id
Scenario: A created translation opens in the editor
When the request answers 201 with a new article id 14
Then the dialog closes and the route becomes /article/14/edit
And a polite announcement reads "German draft created"
And the language menu of the new version lists German as current
Scenario: Unsaved edits are not lost on the way to the new version
Given the owner has unsaved edits to the open version
When the translation is created
Then the "Leave without saving?" dialog of localization_editor_navigation opens with "Keep editing", "Discard and open German" and "Save and open German"
And choosing "Keep editing" leaves the owner on the open version, whose language menu now lists the new version
And the copy was taken from the saved server version, never from the unsaved edits
Scenario Outline: Failures keep the entries
Given the dialog is open with entries for German
When POST /v1/article/12/translations answers <response>
Then <state>
And the title, language, source and starting content are kept
Examples:
| response | state |
| 409 "language_exists", X-Existing-Article-Id 11 | loc.dup.readable: an error alert "German already exists for this article" with "Open it to continue. Your entries are kept here." and the action "Open German version"; the primary is disabled |
| 409 "language_unavailable", German still enabled in GET languages | loc.dup.hidden: an error alert "German can't be created for this article" with "Try a different language, or ask an owner. Your entries are kept."; no Open action, no title and no hint that a version exists; the primary is disabled |
| 409 "language_unavailable", German no longer enabled in GET languages | loc.l4.langOff: Language is read-only with the error "German was disabled on this site." and a warning "German was disabled" with "German was disabled on this site while you were filling this in. Ask an owner to re-enable it, or choose another language. Your entries are kept."; the primary is disabled |
| 422 "language_not_enabled" | loc.l4.langOff, as above |
| 404 "source_unavailable" | loc.l4.unreadable: a warning "The source isn't available to you" with "You can no longer read the version you chose. Choose another source you can read, or start empty. Your title is kept."; the Source version shows "Choose a version"; the primary is disabled |
| 500, or never reaches the server | loc.l4.fail: an error alert "Couldn't create the translation" with "Your entries are kept. Try again."; the primary reads "Try again" |
| 403 "missing_permission" | loc.l4.fail, and no global missing-permission toast |
| 400 with a validate message | loc.l4.fail |
Scenario: A source that answers 404 Article not found in the New article modal
When the New article modal reads its translation source and the answer is 404 "Article not found"
Then the modal shows the unreadable-source alert with neutral copy that does not claim the article was deleted
And the Add translation dialog keeps closing with the Version unavailable state
Scenario: Open the existing version
Given the dialog shows loc.dup.readable for German with X-Existing-Article-Id 11
When the owner activates "Open German version"
Then the dialog closes and the version switch of localization_editor_navigation runs for article 11, including its unsaved-changes dialog
Scenario: The three neutral answers look the same
Given 409 "language_unavailable" for an unreadable version, for a lost race and for a still-enabled language
Then the dialog shows the same loc.dup.hidden state, with no id, header or title read from the response
Scenario: The open version disappears while the dialog is open
When POST /v1/article/12/translations answers 404 "Article not found"
Then the dialog closes and the editor shows the Version unavailable state
Scenario Outline: Recovering from a blocking state
Given the dialog shows <state>
When the owner <action>
Then <result>
Examples:
| state | action | result |
| loc.l4.unreadable | chooses another readable source | the warning disappears and the primary is enabled |
| loc.l4.unreadable | chooses "Start empty" | the warning disappears and the primary is enabled |
| loc.l4.langOff | chooses another language | the warning disappears and the primary is enabled |
| loc.dup.readable | changes the language | the alert disappears and the primary is enabled |
Scenario: The dialog is keyboard operable
Given the dialog is open
Then focus is trapped inside it and starts on the first field
When the owner presses Escape and no request is in flight
Then it closes, the dialog parameters leave the URL and focus returns to the control that opened it
When a request is in flight
Then Escape does nothing
Scenario: A 401 is handled by the global handler
When POST /v1/article/12/translations answers 401
Then the app navigates to /login?redirect=<path>&reason=expired and nothing about the dialog is stored
Rule: The New article modal
Scenario Outline: The modal follows the site chosen in the Site field
Given GET /v1/site/ lists Journal (key journal, default), Atelier, Notes and Archive, and the modal is open
And GET /v1/site/<key>/languages answers <languages>
When the Site field shows <site>
Then <fields>
Examples:
| site | key | languages | fields |
| Archive | archive | en only | loc.l3.single: Title and Site only, no Language field, no New article / Translation choice |
| Journal | journal | en (default), fr, de, ar | loc.l3.multi: Title, Site, Language "English" with the help "Default for Journal.", and a choice "New article" (chosen, "Starts an article in this language.") or "Translation of an existing article" ("Uses the same rules as Add translation.") |
| Journal | journal | request fails | Title and Site only; the article is created with that site and without a lang |
Scenario Outline: The Site field lists the sites and starts on the default site
Given GET /v1/site/ answers <sites>
When the user opens "New article"
Then <result>
Examples:
| sites | result |
| Journal (default), Atelier, Notes, Archive | the Site field is a select in the order of the response, with Journal chosen, between Title and Language, as drawn |
| Archive only (default) | the Site field is still shown as drawn in loc.l3.single, with Archive chosen and no other option |
| the request fails or answers an empty list | the modal has Title only and creates the article as today, POST /v1/article/ with {"title":"…"}, without site and without lang |
Scenario: Changing the site resets a language the new site does not offer
Given the modal is open on Journal with Language "French" and the title "Spring newsletter"
When the user chooses Notes in the Site field and GET /v1/site/notes/languages answers en only
Then loc.l3.sitechange: Language is "English" with the help "Notes only offers English, so the language was reset from French."
And the title is kept and the New article / Translation choice is still shown as drawn
When the user then chooses another site or another language
Then the reset help disappears
Scenario: Changing the site keeps a language the new site offers
Given the modal is open on Journal with Language "French"
When the user chooses Atelier in the Site field
Then Language stays "French", the help reads "Default for Atelier." only when the chosen language is Atelier's default, and no reset message shows
Scenario: The languages of a site that cannot be read
Given the user chooses Atelier and GET /v1/site/atelier/languages fails
Then the Language field and the choice disappear, the Site field keeps Atelier, and the article is created with {"title":"…","site":"atelier"}
Scenario: The modal copy follows the board
Given the modal is open
Then the title is "New article" and the description reads "Give the article a title. You can write the rest in the editor."
And the confirm button reads "Create article"
And the text "Titles can't be changed later, so check it now." no longer exists anywhere
Scenario: A new article in another site and language
Given the modal is open with Site "Atelier", Language "French" and the title "Couts"
When the user activates "Create article"
Then POST /v1/article/?format=html is sent with {"title":"Couts","lang":"fr","site":"atelier"}
And on 201 the modal closes, the route becomes /article/{id}/edit and the toast "Draft created" shows as before
Scenario: The site key is always sent when the Site field is shown
Given the modal is open on Journal, the default site, with Language "English" and the title "Plain"
When the user activates "Create article"
Then the body is {"title":"Plain","lang":"en","site":"journal"}
Scenario Outline: Failures of a new article
When POST /v1/article/ answers <response>
Then <result>
Examples:
| response | result |
| 400 | the existing "Title rejected" state |
| 500 or never reaches the server | the existing Error state |
| 409 "language_unavailable" or 422 "language_not_enabled" | the Language field refetches the languages of the chosen site, resets to its default and the error alert "Couldn't create the article. Your text is still here. Try again in a moment." shows |
| 404 "site_not_found" | the Site field refetches GET /v1/site/, resets to the default site, the title is kept and the error alert "Couldn't create the article. Your text is still here. Try again in a moment." shows |
| 409 "setup_required" | the existing Error state |
| 403 "missing_permission" | the existing Error state, and no global missing-permission toast |
Scenario: Translation of an existing article
Given the modal is open on Journal and the user chose "Translation of an existing article"
Then the Site field is not shown, because a translation lives in the site of its source, as drawn in loc.l3.translation
And the description reads "Create a translation of an article you can read."
And the fields are Article (a searchable select), Language, and Title with the language name, the primary reads "Create French draft"
And the Article select lists only articles the caller can read that have no readable version in the chosen language, from GET /v1/article/?tab=missing&lang=<tag>&q=<search>
And a chosen article shows the help "No French version yet. Copies from English · published" naming its source version
Scenario: Creating the translation from the modal
Given the user chose the article whose default-language version is article 10, Language French and the title "Liste de controle"
When the user activates "Create French draft"
Then POST /v1/article/ is sent with {"title":"Liste de controle","lang":"fr","story_id":5,"source_article_id":10,"copy":true} and no "site"
And on 201 the modal closes, the route becomes /article/{id}/edit and a polite announcement reads "French draft created"
And the failures map to the same alerts as the Add translation dialog (language exists with Open French version, can't be created, couldn't create, source not available)
Scenario: Switching the choice keeps what is shared
Given the user typed a title in the New article choice on Atelier
When the user chooses "Translation of an existing article" and back
Then the title and the chosen site are kept
Rule: Saving sends the revision the editor loaded
Scenario Outline: Every save of the open version carries if_revision
Given the editor loaded revision 3 of article 12
When <action>
Then PATCH /v1/article/12 is sent with "if_revision":3 together with the changed fields
And on 200 the base revision becomes the "revision" of the response
Examples:
| action |
| the owner activates Save |
| the owner confirms "Save and publish French" |
| the owner activates "Save and open English" |
| the owner activates "Try saving again" |
| the owner activates "Try again" in the title helper |
Scenario: Publish and unpublish carry no revision
When the owner publishes or unpublishes article 12
Then the request has no body and the base revision is left unchanged
Scenario: A restored draft keeps the revision it was written against
Given user 7 stored a draft of article 12 written against revision 3 and the server is now at revision 5
When user 7 logs in again and saves the restored draft
Then PATCH /v1/article/12 is sent with "if_revision":3 and the conflict dialog opens with the Restored notice
And a stored draft that has no revision uses the revision loaded now
Rule: The conflict dialog
Scenario: A stale save opens the dialog and sends nothing more
Given the owner has unsaved edits against revision 3 and article 12 is now at revision 4, saved at 14:32 by someone else
When the owner activates Save and PATCH /v1/article/12 answers 409 "revision_conflict" with X-Current-Revision 4
Then GET /v1/article/12 is requested for the time and their version
And a modal dialog titled "Someone else saved this version" reads "Someone else saved this version at 14:32. Your changes aren't saved. They stay here until you choose."
And it offers, in this order, "Keep my changes" (the only filled button, "Nothing is sent. Your changes stay in the editor."), "Download my version" ("Saves your title and body as a text file. Nothing is sent."), "Reload theirs" ("Shows their version. You confirm first.") and "Save over theirs" ("Replaces the title, body and search fields of this version only. Other languages are untouched.")
And "Download my version", "Reload theirs" and "Save over theirs" are secondary buttons of equal weight, as drawn
And no revision number appears, the editor keeps its edits and the state is still unsaved
And focus starts on "Keep my changes", Escape equals "Keep my changes" and focus returns to the title when it closes
Scenario: The dialog is a bottom sheet on mobile
Given the viewport is 390 px wide
Then the dialog is a bottom sheet with "Keep my changes" first and all four options visible without scrolling
Scenario Outline: Conflicts during a chained action stop the chain
Given the owner activated <action>
When the save step answers 409 "revision_conflict"
Then the conflict dialog opens
And <rest> is never sent
Examples:
| action | rest |
| "Save and publish French" | PATCH /v1/article/12/publish |
| "Save and open English" | GET /v1/article/10 |
Scenario: The conflict reload fails
When PATCH answers 409 "revision_conflict" and GET /v1/article/12 answers 500 or never reaches the server
Then no conflict dialog opens and the Save failed state shows with the edits kept
And the next Save sends the same request again
Scenario: Keep my changes
Given the conflict dialog is open
When the owner activates "Keep my changes"
Then the dialog closes, no request is sent and the edits and the base revision are untouched
And the next Save conflicts again with a fresh dialog
Scenario: Download my version
Given the conflict dialog is open
When the owner activates "Download my version"
Then a text file with the unsaved title, a blank line and the body as plain text is downloaded and no request is sent
And a toast reads "Download started" with "Your French title and body are saved as a text file. Your changes are still in the editor."
And a polite announcement reads "Download started", the dialog stays open and the edits are untouched
Scenario: Reload theirs asks first
Given the conflict dialog is open
When the owner activates "Reload theirs"
Then the same dialog switches to the title "Reload theirs?" reading "This discards your unsaved changes."
And it shows "Keep my changes" (filled) and "Reload theirs" with the button "Reload and discard my changes"
When the owner activates "Reload and discard my changes"
Then the editor shows the title and body of the version fetched for the dialog, the state is clean, the stored local draft is removed and the base revision is the fetched revision
When the owner activates "Keep my changes" instead
Then the dialog closes and the edits are untouched
Scenario: Save over theirs uses the revision the dialog showed
Given the conflict dialog showed revision 4
When the owner activates "Save over theirs"
Then PATCH /v1/article/12 is sent with the edits and "if_revision":4
And no newer revision is fetched first
And on 200 the dialog closes, the editor is saved and the base revision is the response revision
And only this version changes, articles 10, 11 and 13 are not requested
Scenario: A second conflict overwrites nothing
Given another writer saved again at 14:41 after the dialog opened
When the owner activates "Save over theirs" and PATCH answers 409 "revision_conflict"
Then GET /v1/article/12 is requested again and the dialog stays open with an error alert "Couldn't replace theirs" and "Someone else saved this version again at 14:41, before your save went through. Nothing was overwritten. Your changes are still here."
And the revision the dialog now shows is the newer one
Scenario: Save over theirs fails for another reason
When PATCH answers 500 or never reaches the server
Then the dialog stays open with an error alert "Couldn't replace theirs" and "Nothing was overwritten. Your changes are still here. Try again."
And "Save over theirs" can be activated again
Scenario: A conflict after a session expiry shows the restored notice
Given user 7 logged in again and the editor shows "Restored unsaved changes from 14:18"
When the owner activates Save and the save answers 409 "revision_conflict"
Then the conflict dialog shows an info alert "Restored unsaved changes from 14:18" with "Your session ended. These changes were kept in this browser." above the four options
Scenario Outline: Other answers to a save during a conflict
When the "Save over theirs" PATCH answers <response>
Then <result>
Examples:
| response | result |
| 401 | the draft is mirrored and the app goes to /login?redirect=/article/12/edit&reason=expired |
| 403 "missing_permission" | the dialog closes, a toast reports the failure and the state is Save failed |
| 404 "Article not found" | the dialog closes and the editor shows the Version unavailable state |
Rule: Deleting one version or the whole article
Scenario Outline: Delete scopes
Given the owner is on <version> and reads <readable>
When the owner opens More actions
Then <menu>
Examples:
| version | readable | menu |
| /article/12 | English, French, German, Arabic | "Delete French version" and "Delete article" are both enabled |
| /article/10 | English only | "Delete article" is enabled and no separate version item shows |
| /article/12 | English, French, German, Arabic, with no delete capability on 13 | "Delete French version" is enabled and "Delete article" is disabled with "You can't delete this article." |
Scenario: Delete French version
Given the owner is on /article/12/edit
When the owner activates "Delete French version"
Then a dialog titled "Delete the French version?" reads ""Couts de renovation" will be deleted. Other versions stay."
And it offers "Cancel" first and "Delete French version" as a danger button
When the owner confirms
Then DELETE /v1/article/12 is sent, the buttons are disabled while in flight and no other article is requested
And on 204 the app goes to /article and a toast reads "French version deleted" with ""Couts de renovation" is gone. Other versions stay."
Scenario: Delete the only version
Given the owner reads English only on /article/10/edit
When the owner activates "Delete article"
Then a dialog titled "Delete the only version?" reads "English is the only version of this article. Deleting it deletes the article."
And it offers "Cancel" and "Delete article" as a danger button
When the owner confirms
Then DELETE /v1/article/10 is sent and on 204 the app goes to /article with the toast "Article deleted"
Scenario: Delete the whole article
Given the owner reads English, French, German and Arabic and holds delete on all four
When the owner activates "Delete article"
Then a dialog titled "Delete English, French, German and Arabic?" reads "This deletes the article and every version you can read. It can't be undone."
And it offers "Cancel" and "Delete article" as a danger button
When the owner confirms
Then DELETE /v1/article/{id} is sent once per readable version, one after another, the other versions first and the open version last, and the sequence stops at the first failure other than 404 (a version already gone is skipped)
And on the last 204 the app goes to /article with the toast "Article deleted"
And no version the owner cannot read is named, counted or requested
Scenario: The whole-article delete is refused by the server
Given the capabilities allowed it and the first DELETE answers 403 "missing_permission"
Then no global missing-permission toast shows and a dialog titled "You can't delete this article" reads "Deleting an article needs delete permission on every version. You can still delete the version you are editing."
And it offers "Close" and "Delete French version"
And no version name is shown
When the owner activates "Delete French version"
Then the dialog switches to the Delete French version confirmation
Scenario: A whole-article delete stops half way
Given the owner confirmed "Delete article" and a DELETE after the first answers 500, 403 or never reaches the server
Then the remaining versions are not requested
And a toast reads "Couldn't delete the whole article" with "Some versions were deleted. Open the language menu to see what is left."
And the editor refetches the open version, or shows the Version unavailable state when the open version is gone
And when the refetch fails a second time for another reason, a polite announcement reads "This page may be out of date. Reload it to see the latest." and the edits are kept
And this copy is proposed by the specifier and is to be confirmed by Claude Design
Scenario: A delete that finds the version already gone
When DELETE /v1/article/12 answers 404
Then the dialog closes and the app goes to /article without an error toast
Scenario: A single-language site keeps the plain wording
Given the owner reads one version on a single-language site
Then the menu item and the dialog read "Delete article" and "Delete this article?" as today, and the dialog names no language
Scenario: Deleting a tracked source is not special-cased
Then the client has no way to know that a sibling tracks the open version, so "Delete the English version?" with "Review tracking will pause" (loc.l8.deleteSource) is not shown in this slice
Rule: Moving an article to another site
Scenario: The panel opens from the site field or the More actions item
Given the owner is on /article/12/edit on a multilingual or multi-site install
When the owner activates the Site field of the details or "Move article"
Then a popover (a bottom sheet on mobile) opens with a listbox named "Site" listing the sites, the current site marked, a note, "Cancel" and "Move article…"
And the note reads "Moves all language versions of this article." and lists the readable versions as "English · Published", "French · Draft", "German · Published" and "Arabic · Draft · Read only"
And "Move article…" stays disabled until another site is chosen
And choosing a site no longer stages a site_id edit and no longer enables Save, because the old choose-a-site-then-Save path is removed from the SPA
Scenario: Confirm
Given the owner chose Atelier in the panel
When the owner activates "Move article…"
Then a dialog titled "Move to Atelier?" reads "This moves every version together. Nothing is split."
And it lists the same versions and an info alert "Addresses and links" reads "Each version keeps its address. Links from the old site to it will stop working. RenewCMS does not create redirects."
And it offers "Cancel" and "Move article"
Scenario: Moving
When the owner confirms
Then POST /v1/story/5/move is sent with {"site":"atelier"}
And the dialog shows "Moving to Atelier…" with "All 4 versions move together. Keep this page open." and an info alert "Moving" with "This takes a few seconds."
And "Cancel" is disabled, the primary reads "Moving…" and busy, and Escape and the backdrop do nothing
Scenario: The move succeeds
When the request answers 200 with {"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]}
Then the dialog closes and a toast reads "Moved to Atelier" with "All 4 versions moved together." and a polite announcement reads "Moved to Atelier"
And the open version is refetched silently so its site and revision are current, and any unsaved edits are kept
And the next Save sends the refetched revision, not a stale one
And when the refetch fails twice for a reason other than 404, a polite announcement reads "This page may be out of date. Reload it to see the latest." and the edits are kept
Scenario Outline: A refused move keeps the dialog open and moves nothing
Given the owner confirmed a move to <destination>
When POST /v1/story/5/move answers <response>
Then <state>
And the primary "Move article" is disabled until another site is chosen
Examples:
| destination | response | state |
| Notes | 422 "language_not_enabled", X-Missing-Languages "fr,de,ar" | loc.l11b.move: a warning "Notes doesn't enable every language in this article" with "Notes enables English only. Enable French, German and Arabic on Notes first. Languages disabled on Journal count too. Nothing was moved." |
| Atelier | 409 "slug_conflict", X-Conflicting-Articles 12 | loc.l11b.moveSlug: an error alert "An address is already used on Atelier" with "The French address "couts" is already used by another French article on Atelier. Change it before moving. Nothing was moved." naming the readable conflicting version only, with no Edit slug button; the slug comes from GET /v1/article/12 |
| Atelier | 403 "missing_permission" | loc.l11b.movePerm: a dialog titled "You can't move this article" with an error alert "Moving needs edit permission on every version" and "Ask an owner to move it. Nothing was changed." and the button "Close"; no global toast |
| Atelier | 500, or never reaches the server | an error alert "Couldn't move the article" with "Nothing was moved. Try again." |
| Atelier | 404 "site_not_found" | the same "Couldn't move the article" alert and the Site list refetched |
| Atelier | 404 "Article not found" | the dialog closes and the editor shows the Version unavailable state |
Scenario: Several versions conflict
When X-Conflicting-Articles names two readable versions
Then the alert names each one with its language and address, one sentence per version, and no hidden version appears
Scenario: The destination's languages are read from the destination
When POST answers 422 "language_not_enabled"
Then GET /v1/site/notes/languages supplies which languages Notes enables, and the missing languages are exactly those of X-Missing-Languages
And a language the owner cannot read is never named
Scenario: Moving with the current site is not possible
Then the current site is marked in the listbox and cannot be confirmed as a move
Scenario: The move panel is keyboard operable
Given the panel is open
Then focus moves into the listbox, arrows move and Enter chooses
When the panel or a dialog closes
Then focus returns to the Site field or to "Move article" in More actions
Scenario: Hidden versions are not announced
Given the caller cannot read the Arabic version
Then the note lists English, French and German only and shows no count and no sentence about unreadable versions, and no footnote about hidden versions is drawn
Rule: Existing behaviour that stays
Scenario: What this feature leaves alone
Then "Edit slug" stays disabled in More actions with "Not available yet." until the Search and sharing slice
And loc.l11.session (the "Your session expired" dialog) is not implemented and the redirect to /login?redirect=<path>&reason=expired stays
And the article list row dialogs for publish and delete keep their present wording
And the freshness badge, the Needs review tab and the source reference pane belong to release 2
And "Save over theirs" keeps the equal-weight secondary look of the design, with no destructive cue, which is recorded as a design gap
Scenario: Replaced behaviour
Then "Changing the site is an edit" in features/sites_authors_integration.feature is superseded by the Move article panel above
And "Titles can't be changed later" and the title-only form in features/new_article_modal.feature are superseded by the New article copy and the Site and Language fields above
And "The Add translation row ... does nothing" and "No if_revision is sent" in features/localization_editor_navigation.feature are superseded by this feature
And "POST /v1/article/ is unchanged when it is not a translation" in features/article_translation_create.feature gains the site rows above and its "creates in the default site" wording becomes "creates in the default site when no site is given"