feat: site languages, setup and site URL settings
This commit is contained in:
1 parent
0806733d5c
commit
d9158025d4
106 files changed
+10184
-56
No files matched your search
@@ -0,0 +1,301 @@
|
||||
Feature: Site languages (API and Languages page)
|
||||
An owner manages the content languages of a site: which are enabled, which
|
||||
is the default, and the social locale of each. Every signed-in user can read
|
||||
the list, only an owner can change it. The whole set is replaced atomically.
|
||||
Nothing here deletes, unpublishes or relabels an article.
|
||||
|
||||
Background:
|
||||
Given the language rules of features/article_localization_storage.feature apply
|
||||
And the domain package internal/domain/language is not modified by this slice
|
||||
And error responses are plain text whose body is the snake_case code
|
||||
And the new rules use the design statuses 409 and 422, existing site errors keep their statuses
|
||||
And an "owner" is a verified user holding settings.manage_sites and a "reader" is a verified user without it
|
||||
And the design in docs/design/localization.md (L1) and screens/localization-site-languages.md is the visual spec of record
|
||||
And version counts are site-level aggregates visible to every signed-in reader, an accepted exception recorded in docs/
|
||||
And site "journal" (id 1, Locale "en") has a confirmed default and these languages
|
||||
| lang | default | enabled | social_locale | versions | published |
|
||||
| en | true | true | null | 7 | 6 |
|
||||
| fr | false | true | fr_FR | 4 | 2 |
|
||||
| de | false | true | null | 4 | 3 |
|
||||
| ar | false | true | null | 1 | 0 |
|
||||
| es | false | false | null | 1 | 0 |
|
||||
| it | false | false | null | 0 | 0 |
|
||||
|
||||
Scenario: Read the languages
|
||||
When a reader sends GET /v1/site/journal/languages
|
||||
Then the status is 200
|
||||
And the body has "revision" (an opaque digest string), "default" "en", "needs_setup" false
|
||||
And "languages" lists en first, then the other enabled languages by tag, then the disabled ones by tag
|
||||
And each language has lang, enabled, is_default, version_count, published_count, social_locale and social_locale_state
|
||||
And "available" lists each selectable tag with a "substitute" that is null or the broader supported tag
|
||||
And a version count covers every non-deleted article of that language, drafts and offline ones included
|
||||
|
||||
Scenario Outline: Read access
|
||||
When <caller> sends GET /v1/site/<key>/languages
|
||||
Then the status is <status> and the body is "<body>"
|
||||
|
||||
Examples:
|
||||
| caller | key | status | body |
|
||||
| an anonymous client | journal | 401 | Unauthorized |
|
||||
| a reader | journal | 200 | |
|
||||
| an owner | journal | 200 | |
|
||||
| an owner | nowhere | 404 | site_not_found |
|
||||
|
||||
Scenario Outline: Social locale states
|
||||
Given site "atelier" has the languages en, en-GB, fr, pt-BR with social_locale null, null, "fr_FR", null
|
||||
When an owner reads its languages
|
||||
Then language "<lang>" has social_locale_state "<state>" and social_locale "<explicit>"
|
||||
And its effective value is "<effective>"
|
||||
|
||||
Examples:
|
||||
| lang | state | explicit | effective |
|
||||
| en-GB | derived | null | en_GB |
|
||||
| pt-BR | derived | null | pt_BR |
|
||||
| fr | set | fr_FR | fr_FR |
|
||||
| en | not_set | null | null |
|
||||
|
||||
Scenario: Only the explicit value and the tag's own region resolve the social locale
|
||||
Given site "atelier" has Locale "en-GB" and a language "en" with social_locale null
|
||||
When an owner reads its languages
|
||||
Then language "en" has social_locale_state "not_set"
|
||||
And matching the Site.Locale language part is deferred to slice 6
|
||||
|
||||
Scenario: A site that still needs setup
|
||||
Given site "notes" has no confirmed default language
|
||||
When a reader sends GET /v1/site/notes/languages
|
||||
Then "needs_setup" is true
|
||||
When an owner sends PUT /v1/site/notes/languages with any body
|
||||
Then the status is 409 and the body is "setup_required"
|
||||
And nothing is stored
|
||||
|
||||
Scenario Outline: Changing the set
|
||||
Given an owner holds the current "revision" of site "journal"
|
||||
When the owner sends PUT /v1/site/journal/languages with that revision and <change>
|
||||
Then the status is 200 and the body is the new read shape
|
||||
And <effect>
|
||||
And no article changes its lang, status or publication
|
||||
|
||||
Examples:
|
||||
| change | effect |
|
||||
| fr added with enabled true | French stays one row, enabled |
|
||||
| pt-br added (lowercase) as enabled | a row "pt-BR" exists and "pt-br" does not |
|
||||
| default "fr" | fr is default, en is no longer default and stays enabled |
|
||||
| de enabled false | de is Disabled, its 4 versions and 3 online ones are untouched |
|
||||
| es enabled true | es is enabled again, its version is untouched |
|
||||
| it omitted | it is gone |
|
||||
| fr social_locale "fr_CA" | fr has social_locale_state "set" and value "fr_CA" |
|
||||
| fr social_locale null | fr has social_locale_state "not_set" |
|
||||
|
||||
Scenario Outline: Rules that reject a change
|
||||
Given an owner holds the current "revision" of site "journal"
|
||||
When the owner sends PUT /v1/site/journal/languages with <body>
|
||||
Then the status is <status> and the body is "<code>"
|
||||
And the stored languages, default and flags are unchanged
|
||||
|
||||
Examples:
|
||||
| body | status | code |
|
||||
| default "en" with en enabled false | 422 | default_not_enabled |
|
||||
| default "ja" | 422 | default_not_enabled |
|
||||
| every language enabled false | 422 | no_enabled_language |
|
||||
| languages [] | 422 | no_enabled_language |
|
||||
| es omitted (1 version) | 409 | language_in_use |
|
||||
| ar omitted (1 draft or offline version) | 409 | language_in_use |
|
||||
| en omitted | 422 | default_not_enabled |
|
||||
| a language "english" | 422 | invalid_language |
|
||||
| a language "xx" | 422 | unsupported_language |
|
||||
| a language "pt-PT" | 422 | unsupported_language |
|
||||
| the same tag twice, "fr" and "FR" | 422 | duplicate_language |
|
||||
| fr social_locale "fr-FR" | 422 | invalid_social_locale |
|
||||
| fr social_locale "fr" | 422 | invalid_social_locale |
|
||||
| fr social_locale "" | 422 | invalid_social_locale |
|
||||
| fr social_locale "pt_BR" | 422 | invalid_social_locale |
|
||||
| no "revision" key | 400 | Invalid request body |
|
||||
| malformed JSON | 400 | Invalid request body |
|
||||
|
||||
Scenario: Social locale format and language part
|
||||
Given the format is "ll_RR" or "lll_RR", letters only and one underscore
|
||||
And the language part must equal the language part of the row's tag
|
||||
Then "fr_FR" and "fr_CA" are valid on "fr", and "pt_BR" is invalid on "fr"
|
||||
|
||||
Scenario: One bad entry rolls back the whole request
|
||||
Given an owner holds the current "revision" of site "journal"
|
||||
When the owner sends a PUT that makes fr the default, disables de and omits es
|
||||
Then the status is 409 and the body is "language_in_use"
|
||||
And fr is not the default, de is still enabled and every row is unchanged
|
||||
|
||||
Scenario: A stale revision is refused
|
||||
Given an owner read revision R1 and another owner then changed the languages
|
||||
When the first owner sends PUT with revision R1
|
||||
Then the status is 409 and the body is "languages_changed"
|
||||
And nothing is applied
|
||||
When the first owner reads again and repeats the change with the new revision
|
||||
Then the status is 200
|
||||
|
||||
Scenario: Sending the same state again is a no-op
|
||||
Given an owner sends a valid PUT and receives revision R2
|
||||
When the same body is sent again with revision R2
|
||||
Then the status is 200 and the revision is still R2
|
||||
And no row has changed
|
||||
|
||||
Scenario Outline: Only an owner can write
|
||||
When <caller> sends PUT /v1/site/journal/languages with a valid body
|
||||
Then the status is <status> and nothing is stored
|
||||
|
||||
Examples:
|
||||
| caller | status |
|
||||
| an anonymous client | 401 |
|
||||
| a reader | 403 |
|
||||
|
||||
Scenario: A soft-deleted version keeps its language in use
|
||||
Given language "it" of site "journal" has no live version and one soft-deleted version
|
||||
When an owner sends PUT /v1/site/journal/languages that omits "it"
|
||||
Then the status is 409 and the body is "language_in_use"
|
||||
And the version count shown for "it" stays 0
|
||||
|
||||
Scenario: A busy site lock is a conflict, never a server error
|
||||
Given another change holds the lock of site "journal"
|
||||
When an owner sends PUT /v1/site/journal/languages with the current revision
|
||||
Then the status is 409 and the body is "languages_changed"
|
||||
And nothing is applied
|
||||
And every lock error of the PUT (site lock, reads, writes, commit) is answered the same way on SQLite, MySQL and PostgreSQL
|
||||
|
||||
Scenario: Concurrent create and PUT on SQLite
|
||||
Given a database on SQLite
|
||||
When a PUT of the languages races the creation of another site
|
||||
Then the PUT answers 200 or 409 and never 500
|
||||
And the site keeps exactly one default language
|
||||
|
||||
Scenario: Changing the languages is recorded in the activity log
|
||||
When an owner changes the set, the default or a social locale
|
||||
Then one "site.languages_edit" event of the sites category targets the site once the change is committed
|
||||
And its changes list the default_language when it changed and the languages before and after
|
||||
When the PUT changes nothing or is a read, no event is recorded
|
||||
When a PUT is refused, one failed event with a plain reason is recorded and an unknown site key records nothing
|
||||
|
||||
Scenario: Removing a language is decided under the site lock
|
||||
Given an offline article is being created in site "journal" while an owner makes "fr" the default
|
||||
Then every created article ends with the default that was current when it took the site lock
|
||||
And none is left with lang ""
|
||||
And a language used by a version at the time of a remove request is never removed
|
||||
|
||||
Scenario: Persistence additions are safe to migrate
|
||||
Given a database migrated by slice 1 with site_languages rows
|
||||
When database.Migrate runs
|
||||
Then site_languages gains a nullable social_locale column of at most 35 characters, null on every row
|
||||
And sites gains a nullable public_url_pattern column
|
||||
And running Migrate again changes nothing
|
||||
|
||||
Scenario: Persistence portability of this slice
|
||||
Given the setup transaction, the PUT atomicity and the new columns
|
||||
Then one targeted task test:mysql and task test:postgres run covers them on MySQL 8.4, MariaDB 11 and PostgreSQL
|
||||
|
||||
Scenario: Sites created after the migration have no language row
|
||||
Given a site is created after slice 1
|
||||
Then it has no site_languages row and appears as pending setup
|
||||
And seeding a suggested row on site create is out of scope and tracked as a follow-up
|
||||
|
||||
Scenario: Existing behaviour is unchanged
|
||||
Then the existing site, article and slice 1 tests pass without edits
|
||||
And no article read changes shape
|
||||
|
||||
Scenario: Route and access
|
||||
Given the route /settings/sites/:key/languages
|
||||
Then it is open to every verified signed-in user
|
||||
And owners see the controls, other users see the Read-only view
|
||||
And an unknown :key shows the Load failed state
|
||||
|
||||
Scenario: Entry points from Settings, Sites
|
||||
Given an owner on Settings, Sites
|
||||
Then each site row has the actions "Languages" and "Public URL"
|
||||
And they open /settings/sites/:key/languages and /settings/sites/:key/public-url
|
||||
And non-owners reach those pages by URL only
|
||||
And the frame for these row actions is pending the designer, so only their presence and targets are asserted
|
||||
|
||||
Scenario Outline: Page states
|
||||
When <trigger>
|
||||
Then the screen is "<state>" (<id>) and shows <visible>
|
||||
|
||||
Examples:
|
||||
| trigger | state | id | visible |
|
||||
| the GET is in flight | Loading | loc.l1.loading | heading "Languages", subtitle "Content languages for Journal. Each article version is written in one language.", 4 skeleton rows |
|
||||
| the GET fails (network or 5xx) | Load failed | loc.l1.error | alert "Couldn't load languages" / "Check your connection and try again." and a "Try again" button that repeats the GET |
|
||||
| an owner opens Archive (one language pt-BR) | Single language | loc.l1.single | the card "Archive uses one language", "New articles are written in Portuguese (Brazil). Add a language to write translations and publish them separately.", Default tag, "pt-BR", "Social locale" "Derived: pt_BR", the alert "Regional language kept: pt-BR", and the "Add language" button |
|
||||
| an owner opens Journal | Multilingual | loc.l1.multi | the table headed "Language"; one row per language with name, code, a Default tag or a Disabled tag, "4 versions · 2 published", "Social locale" value, an "Edit" button (label "Edit social locale for French"), a "Re-enable" button on disabled rows, and an actions button (label "Actions for French"); the footnote "Disabling a language keeps its versions readable and editable, and published ones stay online. Drafts can be published again after you re-enable it. Social locale: Derived comes from the language code, Set is chosen by an owner, Not set leaves og:locale out." |
|
||||
| a reader opens Journal | Read-only view | loc.l1.readonly | the alert "Only owners can change languages" / "You can see which languages are enabled, their social locale and how many versions each has. Ask an owner to change them.", the same rows and no Add language, Edit, Re-enable or actions button |
|
||||
| an owner opens Atelier | Social locale | loc.l1.social | "Derived: en_GB" and "Derived: pt_BR", "Set: fr_FR" and "Not set" shown in the rows (1440, 1024, 390) |
|
||||
|
||||
Scenario Outline: Add a language
|
||||
Given an owner on Journal clicks "Add language"
|
||||
Then the dialog "Add a language" opens with "Choose a language for Journal. Regional variants such as English (United Kingdom) are separate entries."
|
||||
When <action>
|
||||
Then the screen is "<state>" (<id>) and <result>
|
||||
|
||||
Examples:
|
||||
| action | state | id | result |
|
||||
| the dialog is open with Portuguese (Brazil) chosen | Add a language | loc.l1.add | read-only "Language code" "pt-BR" ("Used in the API and in page language metadata.") and "Social locale" "Derived: pt_BR" are shown; buttons "Cancel" and "Add language" |
|
||||
| the owner confirms | Saved | loc.l1.saved | a PUT with the full state plus the new row is sent, the dialog closes, and the toast "French added" / "Writers can now create French versions." appears |
|
||||
| the PUT fails with a server error | Add failed | loc.l1.saveFail | the dialog stays open, the selection is kept, the alert "Couldn't save languages" / "Nothing changed. Your selection is kept. Try again." appears and the button reads "Try again" |
|
||||
| French is chosen and it is already enabled | Duplicate language | loc.l1.adddup | the field error "French is already enabled for Journal." shows, "Add language" is disabled and no request is sent |
|
||||
| Portuguese (Portugal) is chosen (substitute "pt") | Only a broader tag supported | loc.l1.broader | the warning "Portuguese (Portugal) isn't available" / "Only the broader language Portuguese (pt) is supported. Pages and the API will say pt, not pt-PT. Articles written in this language can't be told apart from other Portuguese.", a checked box "Add Portuguese (pt) instead", and the buttons "Cancel" and "Add Portuguese"; "pt" is sent only once that box is checked |
|
||||
|
||||
Scenario: Adding a language that is already listed but disabled
|
||||
Given Spanish is listed on Journal as disabled
|
||||
When the owner opens the Add a language dialog
|
||||
Then Spanish is listed as "Disabled — re-enable instead" and cannot be added
|
||||
And this state has no frame, so the copy is the assumption recorded in the QA notes
|
||||
|
||||
Scenario: The row menu offers three items
|
||||
Given an owner on Journal opens "Actions for French"
|
||||
Then the menu offers "Make default", "Disable" and "Remove"
|
||||
And "Re-enable" is a separate button on disabled rows, not a menu item
|
||||
And the menu itself is not drawn, so the items are inferred from the frames and recorded in the QA notes
|
||||
|
||||
Scenario Outline: Row actions
|
||||
Given an owner on Journal
|
||||
When <action>
|
||||
Then the screen is "<state>" (<id>) and <result>
|
||||
|
||||
Examples:
|
||||
| action | state | id | result |
|
||||
| the French row menu "Make default" is chosen | Make default | loc.l1.default | the dialog "Make French the default language?" with "New articles start in the default language, and it is the first choice for each article's title in the list.", the info alert "No content changes", and the buttons "Cancel" and "Make French default" |
|
||||
| the PUT of that change fails | Set default failed | loc.l1.defaultFail | the dialog shows the alert "Couldn't change the default language" / "Nothing changed. Try again." and "Try again" |
|
||||
| the German row menu "Disable" is chosen | Disable with content | loc.l1.disable | the dialog "Disable German?" with "German is used by 4 versions: 1 draft and 3 published.", the warning "Nothing is deleted or unpublished" and the buttons "Cancel" and "Disable German" |
|
||||
| the PUT of that change returns languages_changed | Languages changed | loc.l1.stale | the alert "Languages changed. Reload." / "Another owner changed the languages for Journal while this page was open. Reload to see the current list. Your change wasn't applied." and a "Reload" button that repeats the GET |
|
||||
| "Disable" is chosen on the default language English | Disable default blocked | loc.lang.disableDefault | no request is sent; the warning "English is the default" / "Choose another default first, then disable English." |
|
||||
| "Disable" is chosen on the only enabled language | Disable last blocked | loc.lang.disableLast | no request is sent; the warning "At least one language must stay enabled" / "Journal needs a language for new articles." |
|
||||
| "Re-enable" is chosen on Spanish | Re-enable | loc.l1.reenable | the dialog "Re-enable Spanish?" with "Writers can create Spanish versions again. The 1 existing Spanish version stays as it is. Nothing else changes." and "Re-enable Spanish" |
|
||||
| the owner confirms | Re-enabled | loc.l1.reenableDone | the toast "Spanish re-enabled" / "Writers can create Spanish versions again. Drafts can be published." |
|
||||
| the PUT of that change fails | Re-enable failed | loc.l1.reenableFail | the dialog alert "Couldn't re-enable Spanish" / "Nothing changed. Spanish is still disabled. Try again." and "Try again" |
|
||||
| "Remove" is chosen on Italian (0 versions) | Remove (no content) | loc.l1.remove | the dialog "Remove Italian?" / "No article uses Italian. You can add it again later." and a danger button "Remove Italian" |
|
||||
| the PUT of that change fails | Remove failed | loc.l1.removeFail | the alert "Couldn't remove Italian" / "Nothing changed. Italian is still listed. Try again." and a danger "Try again" |
|
||||
| "Remove" is chosen on Spanish (1 version) | Remove blocked | loc.l1.removeBlocked | no request is sent; the dialog "Spanish can't be removed" / "Spanish is used by 1 version. Disable the language to keep it, or delete it first." with "Close" and "Disable Spanish" |
|
||||
|
||||
Scenario: A server-side language_in_use refusal is shown as Remove blocked
|
||||
Given another owner created a Spanish version after this page loaded
|
||||
When the owner confirms "Remove Spanish"
|
||||
Then the PUT returns 409 "language_in_use"
|
||||
And the screen shows the Remove blocked dialog for Spanish and the list reloads
|
||||
|
||||
Scenario Outline: Social locale
|
||||
Given an owner on Atelier clicks "Edit" on the French row
|
||||
When the owner types "<input>"
|
||||
Then the screen is "<state>" (<id>) and shows <visible>
|
||||
|
||||
Examples:
|
||||
| input | state | id | visible |
|
||||
| fr_FR | Social locale: edit | loc.l1.socialEdit | the dialog "Social locale for French" / "Used for og:locale on French pages. Leave it empty to omit the tag." with the monospace field "fr_FR", the help "Format: language_REGION, for example fr_FR. Letters only, one underscore." and "Cancel" and "Save social locale" |
|
||||
| fr-FR | Social locale: invalid | loc.l1.socialInvalid | the field error "Use letters and one underscore, for example fr_FR." and "Save social locale" disabled |
|
||||
|
||||
Scenario: Saving a social locale
|
||||
When the owner saves "fr_CA", or clears the field
|
||||
Then a PUT with the full state is sent with social_locale "fr_CA", or null for an empty field
|
||||
And a server 422 "invalid_social_locale" shows the same field error as the invalid state
|
||||
|
||||
Scenario: Setup banner
|
||||
Given GET /v1/site/archive/languages returned "needs_setup" true
|
||||
Then the page shows a banner with an action to /settings/sites/archive/languages/setup
|
||||
And the banner has no frame, so only its presence and target are asserted and its copy is pending the designer
|
||||
|
||||
Scenario: Failure states with no frame
|
||||
Then the Languages page failure reuses the Load failed copy
|
||||
And the board's it, ja and zh-Hans tags are visual mocks only, the backend never holds them
|
||||
Reference in new issue
Block a user