635 lines
51 KiB
Gherkin
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"
|