From 45fbbc33b109ba402f7fa9568571f044427607e2 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Tue, 3 Nov 2026 00:12:29 +0100 Subject: [PATCH] feat: translation flows, revision conflicts, delete scopes, article moves and a target site for new articles --- api/handlers/article/articleHandler.go | 16 +- api/pkg/apperr/messages.go | 1 + docs/api/article-localization.md | 23 +- docs/api/articles.md | 3 +- docs/llm/recipe-articles.md | 2 +- features/article_translation_create.feature | 4 +- features/article_translation_create.qa.md | 4 + features/articles_list.feature | 2 +- features/articles_list.qa.md | 4 + features/editor_core.feature | 8 +- features/editor_core.qa.md | 4 + .../localization_editor_navigation.feature | 6 +- features/localization_editor_navigation.qa.md | 6 +- .../localization_translation_flows.feature | 634 ++++++++++++++++++ features/localization_translation_flows.qa.md | 121 ++++ features/new_article_modal.feature | 28 +- features/new_article_modal.qa.md | 17 +- features/sites_authors_integration.feature | 18 +- features/sites_authors_integration.qa.md | 2 +- internal/domain/audit/auditDomain.go | 1 + .../infrastructure/useCases/CreateArticle.go | 23 +- .../infrastructure/useCases/articleTarget.go | 10 + .../infrastructure/useCases/auditFailures.go | 1 + test/article_create_language_test.go | 52 ++ test/article_create_site_http_test.go | 216 ++++++ test/article_translation_create_http_test.go | 3 + test/story_support_test.go | 7 +- web/src/components/AddTranslationDialog.vue | 345 ++++++++++ web/src/components/EditorConflictDialog.vue | 255 +++++++ web/src/components/EditorTaskDialog.vue | 51 ++ web/src/components/MetaSection.vue | 10 +- web/src/components/MoveArticleDialog.vue | 164 +++++ web/src/components/MoveArticlePanel.vue | 124 ++++ web/src/components/NewArticleModal.vue | 440 ++++++++++-- web/src/components/SiteMoveField.vue | 41 ++ .../__tests__/NewArticleModal.spec.ts | 57 +- .../__tests__/NewArticleModalSites.spec.ts | 508 ++++++++++++++ .../localization/LocaleChoiceCards.vue | 36 + .../components/localization/LocaleDialog.vue | 16 +- .../components/localization/LocaleSelect.vue | 3 +- .../localization/TranslationFailure.vue | 71 ++ web/src/composables/__tests__/useApi.spec.ts | 97 +++ .../__tests__/useArticleDraft.spec.ts | 108 +++ .../__tests__/useEditorMove.spec.ts | 48 ++ web/src/composables/useApi.ts | 39 +- web/src/composables/useArticleDraft.ts | 96 ++- web/src/composables/useEditorConflict.ts | 97 +++ web/src/composables/useEditorDeletion.ts | 222 ++++++ web/src/composables/useEditorMove.ts | 173 +++++ web/src/composables/useEditorSeoPanel.ts | 2 +- web/src/composables/useEditorTranslation.ts | 149 ++++ web/src/composables/useEditorVersions.ts | 21 + web/src/composables/useNewArticleSites.ts | 65 ++ web/src/composables/useVersionSwitch.ts | 8 +- web/src/i18n/messages.ts | 2 + web/src/i18n/metaMessages.spec.ts | 1 + web/src/i18n/translationFlowMessages.spec.ts | 20 + web/src/i18n/translationFlowMessages.ts | 155 +++++ .../__tests__/addTranslationLocation.spec.ts | 25 + .../__tests__/languageAfterSiteChange.spec.ts | 32 + .../lib/__tests__/translationFailure.spec.ts | 47 ++ web/src/lib/addTranslationLocation.ts | 15 + web/src/lib/apiError.ts | 20 + web/src/lib/articleTypes.ts | 2 + web/src/lib/bodyPlainText.ts | 16 + web/src/lib/clockTime.ts | 5 + web/src/lib/editorNotices.ts | 7 +- web/src/lib/editorOverflow.ts | 15 +- web/src/lib/languageAfterSiteChange.ts | 15 + web/src/lib/moveArticle.ts | 27 + web/src/lib/storedDraft.ts | 2 + web/src/lib/translationFailure.ts | 60 ++ web/src/lib/translationSource.ts | 68 ++ web/src/views/ArticleView.vue | 17 +- web/src/views/EditorView.vue | 167 ++++- web/src/views/HomeView.vue | 10 +- .../__tests__/ArticleLocalization.spec.ts | 15 + .../__tests__/EditorAddTranslation.spec.ts | 544 +++++++++++++++ .../views/__tests__/EditorDeletion.spec.ts | 313 +++++++++ .../__tests__/EditorHeaderActions.spec.ts | 20 +- .../__tests__/EditorLanguageMenu.spec.ts | 10 +- .../views/__tests__/EditorMetaSection.spec.ts | 96 ++- web/src/views/__tests__/EditorMove.spec.ts | 522 ++++++++++++++ .../views/__tests__/EditorRevisions.spec.ts | 543 +++++++++++++++ web/src/views/__tests__/EditorTitle.spec.ts | 6 +- web/src/views/__tests__/HomeView.spec.ts | 2 +- web/src/views/__tests__/editorKit.ts | 2 + web/src/views/__tests__/localizationKit.ts | 5 +- .../__tests__/localizationScreen.spec.ts | 1 + web/visual/articleListStates.ts | 2 +- web/visual/editorLocalizationStates.ts | 322 ++++++++- web/visual/newArticleLocalizationStates.ts | 325 +++++++++ web/visual/states.ts | 6 +- 93 files changed, 7630 insertions(+), 294 deletions(-) create mode 100644 features/localization_translation_flows.feature create mode 100644 features/localization_translation_flows.qa.md create mode 100644 internal/infrastructure/useCases/articleTarget.go create mode 100644 test/article_create_site_http_test.go create mode 100644 web/src/components/AddTranslationDialog.vue create mode 100644 web/src/components/EditorConflictDialog.vue create mode 100644 web/src/components/EditorTaskDialog.vue create mode 100644 web/src/components/MoveArticleDialog.vue create mode 100644 web/src/components/MoveArticlePanel.vue create mode 100644 web/src/components/SiteMoveField.vue create mode 100644 web/src/components/__tests__/NewArticleModalSites.spec.ts create mode 100644 web/src/components/localization/LocaleChoiceCards.vue create mode 100644 web/src/components/localization/TranslationFailure.vue create mode 100644 web/src/composables/__tests__/useEditorMove.spec.ts create mode 100644 web/src/composables/useEditorConflict.ts create mode 100644 web/src/composables/useEditorDeletion.ts create mode 100644 web/src/composables/useEditorMove.ts create mode 100644 web/src/composables/useEditorTranslation.ts create mode 100644 web/src/composables/useNewArticleSites.ts create mode 100644 web/src/i18n/translationFlowMessages.spec.ts create mode 100644 web/src/i18n/translationFlowMessages.ts create mode 100644 web/src/lib/__tests__/addTranslationLocation.spec.ts create mode 100644 web/src/lib/__tests__/languageAfterSiteChange.spec.ts create mode 100644 web/src/lib/__tests__/translationFailure.spec.ts create mode 100644 web/src/lib/addTranslationLocation.ts create mode 100644 web/src/lib/bodyPlainText.ts create mode 100644 web/src/lib/clockTime.ts create mode 100644 web/src/lib/languageAfterSiteChange.ts create mode 100644 web/src/lib/moveArticle.ts create mode 100644 web/src/lib/translationFailure.ts create mode 100644 web/src/lib/translationSource.ts create mode 100644 web/src/views/__tests__/EditorAddTranslation.spec.ts create mode 100644 web/src/views/__tests__/EditorDeletion.spec.ts create mode 100644 web/src/views/__tests__/EditorMove.spec.ts create mode 100644 web/src/views/__tests__/EditorRevisions.spec.ts create mode 100644 web/visual/newArticleLocalizationStates.ts diff --git a/api/handlers/article/articleHandler.go b/api/handlers/article/articleHandler.go index 845de3c..ca952de 100644 --- a/api/handlers/article/articleHandler.go +++ b/api/handlers/article/articleHandler.go @@ -42,6 +42,7 @@ type PostArticle struct { StoryID *uint32 `json:"story_id" validate:"omitempty,gte=1"` SourceArticleID *uint32 `json:"source_article_id" validate:"omitempty,gte=1"` Copy *bool `json:"copy"` + Site *string `json:"site" validate:"omitempty,sitekey"` } type PostTranslation struct { @@ -237,11 +238,15 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) { } if err := validator.Validate.Struct(localArticle); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writeValidationError(w, err) return } if localArticle.StoryID != nil { + if localArticle.Site != nil { + http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) + return + } h.postTranslation(w, r, format, localArticle) return } @@ -259,6 +264,7 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) { Body: localArticle.Body, Format: format, Lang: lang, + Site: localArticle.Site, Caller: access.CallerOf(r), }) if err != nil { @@ -336,6 +342,8 @@ func writeKnownArticleError(w http.ResponseWriter, err error) bool { return access.WriteIfMissingPermission(w, err) || articleerr.Write(w, err) } +const siteField = "Site" + var bodyMismatchFailures = map[string]string{ "Copy": "required", "IfRevision": "min", @@ -351,6 +359,12 @@ func writeValidationError(w http.ResponseWriter, err error) { return } } + for _, failure := range failures { + if failure.Field() == siteField { + http.Error(w, apperr.InvalidSiteKeyErrorMessage, http.StatusBadRequest) + return + } + } if len(failures) > 0 && bodyMismatchFailures[failures[0].Field()] == failures[0].Tag() { http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) return diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index 729b90b..b7be1d6 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -63,6 +63,7 @@ const CachePurgeTargetErrorMessage = "A variant name or an image id is required const VariantUnknownMessage = "Unknown variant" const VariantUnsupportedParameterMessage = "Unsupported parameter" const SiteNotFoundErrorMessage = "site_not_found" +const InvalidSiteKeyErrorMessage = "invalid_site_key" const SiteKeyTakenErrorMessage = "site_key_taken" const SiteHasArticlesErrorMessage = "site_has_articles" const DefaultSiteErrorMessage = "default_site" diff --git a/docs/api/article-localization.md b/docs/api/article-localization.md index fc8820f..de548eb 100644 --- a/docs/api/article-localization.md +++ b/docs/api/article-localization.md @@ -28,25 +28,25 @@ The headers are set on those responses only. Same-origin SPA code reads them dir | --- | --- | --- | | 400 | `The request cannot be processed due to a mismatch in the format of the body.` | undecodable or invalid body, missing `copy`, `copy: false` with a source, `if_revision` invalid, an unknown or empty update | | 400 | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` | invalid id in the path | -| 400 | the validator text (for example `Key: 'PostTranslation.Title' Error:Field validation for 'Title' failed on the 'min' tag`) | `lang` or `title` missing, title shorter than 3 or longer than 200 characters on a create | +| 400 | the validator text (for example `Key: 'PostTranslation.Title' Error:Field validation for 'Title' failed on the 'min' tag`) | `lang` or `title` missing, title shorter than 3 or longer than 200 characters on a create, `site` that is `""` or not a site key on `POST /v1/article/` | | 400 | `Article title must be between 3 and 200 characters.` | a `PATCH` title outside 3 to 200 runes | | 400 | `invalid_slug` | a `PATCH` slug that is not a slug | | 401 | `You must be logged in to access this resource.` | no valid session or key | | 403 | `missing_permission` | the caller lacks the permission (`X-Missing-Permission`) | | 404 | `Article not found` | the version, or the story, does not exist or the caller cannot read it | | 404 | `source_unavailable` | the chosen source does not exist or is not readable; the same answer for both | -| 404 | `site_not_found` | `POST /v1/story/{story_id}/move` with an unknown site key | +| 404 | `site_not_found` | `POST /v1/story/{story_id}/move`, or `POST /v1/article/` with `site`, naming an unknown site key | | 409 | `revision_conflict` | `if_revision` is not the current `revision` (`X-Current-Revision`) | | 409 | `language_exists` | the language already has a readable version (`X-Existing-Article-Id`) | -| 409 | `language_unavailable` | one neutral answer: the language is disabled on the site, or its version exists but is not readable by the caller, or a concurrent request created it first | -| 409 | `setup_required` | the version has no language yet (`lang` `""`): the site default is not confirmed | +| 409 | `language_unavailable` | one neutral answer: the language is disabled on the site (the chosen site on `POST /v1/article/` with `site`), or its version exists but is not readable by the caller, or a concurrent request created it first | +| 409 | `setup_required` | the version has no language yet (`lang` `""`): the site default is not confirmed (the chosen site on `POST /v1/article/` with `site`) | | 409 | `slug_taken` | a `PATCH` slug used by another article of the same site and language (`X-Slug-Suggestion`) | | 409 | `slug_conflict` | a move would put an address that already exists at the destination (`X-Conflicting-Articles`) | | 409 | `story_move_required` | `PATCH site_id` on a version whose story holds several versions | | 409 | `story_busy` | the database could not lock the rows in time (lock timeout, deadlock or serialization failure); retry | | 422 | `invalid_language` | the tag is not a language tag | | 422 | `unsupported_language` | a valid tag outside the supported list | -| 422 | `language_not_enabled` | the site has no language row for the tag (creation), or a destination site lacks or has disabled a language of the story (move, `X-Missing-Languages`) | +| 422 | `language_not_enabled` | the site (the chosen one on `POST /v1/article/` with `site`) has no language row for the tag (creation), or a destination site lacks or has disabled a language of the story (move, `X-Missing-Languages`) | | 422 | `language_disabled` | publishing an offline version whose language is disabled on its site | | 422 | `source_other_story` | the source belongs to another story | @@ -138,7 +138,18 @@ Answers `201` with the created article object. Order of checks: permission, path ### `POST /v1/article/` without `story_id` -Unchanged: it creates a new story with one offline version. `lang` is now optional: absent, the version gets the starting language of the site as before; present, it is normalized and must be an enabled language of the default site (`422 invalid_language`, `unsupported_language`, `language_not_enabled`; `409 language_unavailable` when disabled; `409 setup_required` before the site default is confirmed). +It creates a new story with one offline version, on the default site unless the body names a site. + +```json +{"title": "Neuf", "lang": "fr", "site": "atelier"} +``` + +- `site` is optional: the site key. Absent or `null`, the default site is used exactly as before. A present `site` is validated like any site key (`400` with the plain-text body `invalid_site_key` for `""` or `Bad Key!`), then looked up (`404 site_not_found` for an unknown key, a plain text body, nothing stored). Every caller who may create articles may create on every site: `content.create` is not scoped to a site. +- `lang` is optional. Absent, the version gets the starting language of the chosen site; present, it is normalized and must be an enabled language of the chosen site (`422 invalid_language`, `unsupported_language`, `language_not_enabled`; `409 language_unavailable` when disabled on that site; `409 setup_required` before that site's default language is confirmed). The same tag can therefore be accepted on one site and refused on another. +- The slug is generated from the title and made free within the chosen site and language only. +- `site` with `story_id`, `source_article_id` or `copy` answers `400` with the body mismatch message and stores nothing. `POST /v1/article/{id}/translations` has no `site` field: the translation keeps the site of its source and an unknown `site` key in its body is ignored. +- The response is the created article object (`201`) with the same keys as before; `site_id` is the id of the chosen site. The article is offline, so it is invisible to anonymous reads and public lists, and `GET /v1/article/?site=` lists it for readers. +- `article.create` records `params.site` (the site key) on success; a refusal records one failed event with a plain reason (refusals by validation, `401` and `403` happen before the use case and record nothing). ## Deleting diff --git a/docs/api/articles.md b/docs/api/articles.md index c04fef9..1176944 100644 --- a/docs/api/articles.md +++ b/docs/api/articles.md @@ -95,7 +95,7 @@ Breaking change: the default used to be HTML. Clients that need HTML must send ` } ``` -`lang` is the full BCP 47 language tag of the article (`""` while the site default language is not confirmed) and `story_id` is the story the article belongs to. Both are present on every article object (list items, reads, create and update responses, public reads) as additive keys, and every other key is unchanged. They are read-only on `PATCH`, where `lang` and `story_id` are ignored. `POST /v1/article/` takes `lang`, `story_id`, `source_article_id` and `copy` only as described in [article-localization.md](article-localization.md), which also documents `revision`, `capabilities`, `translations` and `source`, the additive keys of every article read. Supported tags: `en`, `en-GB`, `en-US`, `fr`, `fr-CA`, `pt`, `pt-BR`, `es`, `de`, `ar`. Existing articles each get their own story and `lang` `""` until the site default is confirmed. +`lang` is the full BCP 47 language tag of the article (`""` while the site default language is not confirmed) and `story_id` is the story the article belongs to. Both are present on every article object (list items, reads, create and update responses, public reads) as additive keys, and every other key is unchanged. They are read-only on `PATCH`, where `lang` and `story_id` are ignored. `POST /v1/article/` takes `title`, `body`, and optionally `lang`, `site` (a site key; the default site when absent or `null`), `story_id`, `source_article_id` and `copy` only as described in [article-localization.md](article-localization.md), which also documents `revision`, `capabilities`, `translations` and `source`, the additive keys of every article read. Supported tags: `en`, `en-GB`, `en-US`, `fr`, `fr-CA`, `pt`, `pt-BR`, `es`, `de`, `ar`. Existing articles each get their own story and `lang` `""` until the site default is confirmed. `created_by` is the id of the user who created the article (`0` when it is attributed to nobody). It is present on every article object (list, read, create and update responses) only when the caller is signed in with a verified session or authenticates with an API key; the key acts as its creator. Anonymous callers, and unverified or stale sessions, get the same object without the field. The SPA reads it to decide which rows a role limited to `own` can edit, delete or publish. @@ -220,6 +220,7 @@ Example for `

Plan

Replace the old deck.

`: | --- | --- | | `400` | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` for an invalid id | | `400` | `The format must be one of: markdown, html, json.` for an unsupported format | +| `400` | `invalid_site_key` for a `site` of `POST /v1/article/` that is not a valid site key | | `400` | `invalid_slug`, `image_not_found` or the validator text for an over-long SEO field, `cover_image_id: 0` or an unknown `seo_dismissed` code | | `404` | `Article not found` | | `409` | `slug_taken`, with the header `X-Slug-Suggestion: ` (this response only) | diff --git a/docs/llm/recipe-articles.md b/docs/llm/recipe-articles.md index 0df4d4c..0502b75 100644 --- a/docs/llm/recipe-articles.md +++ b/docs/llm/recipe-articles.md @@ -25,7 +25,7 @@ Status `201`: {"id":1,"title":"How we price a bathroom renovation","body":"A bathroom renovation is priced by room size, plumbing changes and finish level.","body_format":"markdown","is_online":false,"site_id":1,"authors":[],"created_at":"2026-10-23T01:11:16.767761797+02:00","updated_at":"2026-10-23T01:11:16.767761797+02:00","slug":"how-we-price-a-bathroom-renovation","excerpt":"","seo_title":"","seo_description":"","cover_image_id":null,"cover_image_alt":"","social_title":"","social_description":"","social_image_id":null,"canonical_url":"","noindex":false,"seo_dismissed":[]} ``` -The article starts offline (`is_online: false`) on the default site. +The article starts offline (`is_online: false`) on the default site. To create it on another site, add `"site":""` to the body (and optionally `"lang":"fr"`, an enabled language of that site); an unknown key answers `404 site_not_found`, and a language the chosen site does not enable answers `422 language_not_enabled`. ## 2. Replace the body, move it to a site, set the excerpt diff --git a/features/article_translation_create.feature b/features/article_translation_create.feature index 15c7c67..3635761 100644 --- a/features/article_translation_create.feature +++ b/features/article_translation_create.feature @@ -147,7 +147,7 @@ Feature: Create a translation of an article version And the result, errors, headers and stored data are exactly those of POST /v1/article/10/translations with the same lang, title and source And the same use case runs for both - Scenario Outline: POST /v1/article/ is unchanged when it is not a translation + Scenario Outline: POST /v1/article/ is unchanged when it is not a translation (it creates in the default site when no site is given; a "site" key names the target site, see localization_translation_flows) Given a valid jwt cookie for a writer When the client sends POST /v1/article/ with Then the response status is @@ -162,6 +162,8 @@ Feature: Create a translation of an article version | {"title":"Neuf","story_id":5} | 400 | the body is apperr.BodyErrorMessage | | {"title":"Neuf","lang":"fr","story_id":999} | 404 | the body is "Article not found" | | {"title":"Neuf","copy":true} | 400 | the body is apperr.BodyErrorMessage | + | {"title":"Neuf","lang":"fr","story_id":5,"site":"atelier"} | 400 | the body is apperr.BodyErrorMessage (a site with a translation is refused) | + | {"title":"Neuf","lang":"fr","site":"atelier"} | 201 | a new story is created in fr on site atelier | Scenario: PATCH still ignores lang, story_id and source fields Given a valid jwt cookie for a writer diff --git a/features/article_translation_create.qa.md b/features/article_translation_create.qa.md index 16e0bef..8cbb8b8 100644 --- a/features/article_translation_create.qa.md +++ b/features/article_translation_create.qa.md @@ -24,3 +24,7 @@ Backend only. Go tests in test/, black-box. No design states. ## Manual None. + +## Amended by localization_translation_flows + +The wording of features/article_translation_create.feature that this slice supersedes is amended in place (PATCH bodies carry if_revision, the Add translation destination, the site key of POST /v1/article/). The Delete dialog of single-language sites keeps its present copy. diff --git a/features/articles_list.feature b/features/articles_list.feature index c29ba66..a868a7c 100644 --- a/features/articles_list.feature +++ b/features/articles_list.feature @@ -1,5 +1,5 @@ Feature: Articles list screen - Superseded in part by article_list_localization: the call is GET /v1/article/?group=story&... and rows are story rows. The row anatomy gains the coverage chips (first) and the "Authors" union. The row menu items and order are Open {language}, Open another language, Add translation, Publish or Unpublish {language}, Copy API URL, Delete {language} version (docs/design/localization-tables.md "Row actions"), each shown by its capability, replacing "Edit, Publish or Unpublish, Copy API URL, Delete…" for rows with those capabilities; the Delete… confirmation copy is unchanged here (slice 5). The tabs "All / Online / Drafts" become the two modes of the new feature: "All articles" and "Needs translation" with no language; "All", "Published", "Drafts" and "Not in {Language}" with one. The 6-article fixture is replaced by the Journal dataset for the grouped scenarios; rows with no lang assigned keep today's scenarios with empty coverage. "Copy API URL" still copies {origin}/v1/article/{id} of the version the row menu acts on. + Superseded in part by article_list_localization: the call is GET /v1/article/?group=story&... and rows are story rows. The row anatomy gains the coverage chips (first) and the "Authors" union. The row menu items and order are Open {language}, Open another language, Add translation, Publish or Unpublish {language}, Copy API URL, Delete {language} version (docs/design/localization-tables.md "Row actions"), each shown by its capability, replacing "Edit, Publish or Unpublish, Copy API URL, Delete…" for rows with those capabilities; the Delete… confirmation copy is unchanged here (slice 5). The Add translation item navigates to /article/{id}/edit?dialog=add-translation, plus &lang= when exactly one language is missing (localization_translation_flows). The tabs "All / Online / Drafts" become the two modes of the new feature: "All articles" and "Needs translation" with no language; "All", "Published", "Drafts" and "Not in {Language}" with one. The 6-article fixture is replaced by the Journal dataset for the grouped scenarios; rows with no lang assigned keep today's scenarios with empty coverage. "Copy API URL" still copies {origin}/v1/article/{id} of the version the row menu acts on. Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission. As a verified RenewCMS owner diff --git a/features/articles_list.qa.md b/features/articles_list.qa.md index 6dbd2fd..d4d55ec 100644 --- a/features/articles_list.qa.md +++ b/features/articles_list.qa.md @@ -178,3 +178,7 @@ file). a running app, since Vitest only asserts `useToast().add` was called with the right arguments. - **Real clipboard write on a live app** — confirm `navigator.clipboard.writeText` actually places the URL on the system clipboard in a real browser, since Vitest only mocks the clipboard API. + +## Amended by localization_translation_flows + +The wording of features/articles_list.feature that this slice supersedes is amended in place (PATCH bodies carry if_revision, the Add translation destination, the site key of POST /v1/article/). The Delete dialog of single-language sites keeps its present copy. diff --git a/features/editor_core.feature b/features/editor_core.feature index ff7e65d..e3d764f 100644 --- a/features/editor_core.feature +++ b/features/editor_core.feature @@ -27,7 +27,7 @@ Feature: Editor screen — load, edit and save an article's body And Save is enabled only when useArticleDraft's state is "dirty", the request is not already in flight, and the app is not offline, whatever the body's length; it is disabled in every other case - And Save (a click, or Ctrl/⌘ S) sends PATCH /v1/article/{id} with JSON {"body": "..."}, + And Save (a click, or Ctrl/⌘ S) sends PATCH /v1/article/{id} with JSON {"body": "...", "if_revision": }, the same contract features/edit_article_body.feature already gives the API: 200 with the updated article.Article (any body length, including empty), 404 for an id absent from the repository, 401 with no valid session @@ -118,14 +118,14 @@ Feature: Editor screen — load, edit and save an article's body Scenario: Saving — Save sends the PATCH and disables itself while in flight Given the user is on /article/12/edit with unsaved changes to the body When the user activates Save - Then PATCH /v1/article/12 is sent with JSON {"body": ""} + Then PATCH /v1/article/12 is sent with JSON {"body": "", "if_revision": } And SaveStatus reads "Saving…" And the Save button is disabled Scenario: Ctrl/⌘ S saves the same way as clicking Save Given the user is on /article/12/edit with unsaved changes to the body When the user presses Ctrl+S (or ⌘+S) - Then PATCH /v1/article/12 is sent with JSON {"body": ""} + Then PATCH /v1/article/12 is sent with JSON {"body": "", "if_revision": } And SaveStatus reads "Saving…" Scenario: A successful save returns to the Saved state with the new time @@ -157,7 +157,7 @@ Feature: Editor screen — load, edit and save an article's body Given the user is on /article/12/edit in the Save failed state, with unsaved body "Revised opening paragraph." and PATCH /v1/article/12 having failed with a 500 When the user activates "Try saving again" - Then PATCH /v1/article/12 is sent again with JSON {"body": "Revised opening paragraph."} + Then PATCH /v1/article/12 is sent again with JSON {"body": "Revised opening paragraph.", "if_revision": } When it resolves 200 with the updated article Then SaveStatus reads "Saved at {current time}" diff --git a/features/editor_core.qa.md b/features/editor_core.qa.md index 3774c12..f3dffbb 100644 --- a/features/editor_core.qa.md +++ b/features/editor_core.qa.md @@ -125,3 +125,7 @@ reusing the sample data below and a fixed `now` of "2026-09-26T11:02:00Z" (updat - **Real `beforeunload` prompt** — Vitest can assert `onBeforeRouteLeave`'s ConfirmDialog, but a real browser tab-close prompt (native `beforeunload`) needs manual confirmation, since jsdom doesn't model it. + +## Amended by localization_translation_flows + +The wording of features/editor_core.feature that this slice supersedes is amended in place (PATCH bodies carry if_revision, the Add translation destination, the site key of POST /v1/article/). The Delete dialog of single-language sites keeps its present copy. diff --git a/features/localization_editor_navigation.feature b/features/localization_editor_navigation.feature index 638612f..726d5b0 100644 --- a/features/localization_editor_navigation.feature +++ b/features/localization_editor_navigation.feature @@ -13,7 +13,7 @@ Feature: Editor language navigation, title, header actions and permission states And site "journal" has enabled languages en (default), fr, de, ar and disabled language es And story 5 holds article 10 (en, online, "Renovation costs"), article 12 (fr, offline, "Couts de renovation"), article 11 (de, offline, not readable to the caller) and article 13 (ar, online, "Takalif") And "owner" holds capabilities ["read","edit","publish","delete","create_translation"] on every version - And PATCH /v1/article/{id} is sent without "if_revision" in this slice (last write wins, as today); the revision conflict dialog belongs to localization_translation_flows + And PATCH /v1/article/{id} carries "if_revision" with the revision the editor loaded, as defined by localization_translation_flows (superseding the earlier last-write-wins behaviour); the conflict dialog belongs to localization_translation_flows Rule: The language menu @@ -21,7 +21,7 @@ Feature: Editor language navigation, title, header actions and permission states Given the owner is on /article/12/edit When the owner activates the language control named "Language: French. Switch version" Then a menu opens, not a listbox, with the rows English, Arabic and French and no German version row - And German appears only as an "Add translation" row, because the owner holds create_translation + And German appears only as an "Add translation" row, because the owner holds create_translation, and activating that row opens the Add a translation dialog of localization_translation_flows And each row shows its language name, its code and a publication badge ("Published" or "Draft") And the row for French is marked current And the order is English (default), Arabic, French, following translations[] @@ -217,7 +217,7 @@ Feature: Editor language navigation, title, header actions and permission states And on mobile it opens as a sheet When the caller lacks the capability for an item Then the item is shown disabled with the reason as visible text and aria-disabled - And Edit slug, Move article and Delete article are shown disabled with the reason "Not available yet." until the slice that wires them + And Edit slug is shown disabled with the reason "Not available yet." until the Search and sharing slice, while Move article and the Delete items act as defined by localization_translation_flows Scenario: Cannot delete the whole article (P6) Given the caller can create and modify only their own versions and holds ["read","edit","publish","delete","create_translation"] on French but not on a sibling diff --git a/features/localization_editor_navigation.qa.md b/features/localization_editor_navigation.qa.md index 8e90341..acd6653 100644 --- a/features/localization_editor_navigation.qa.md +++ b/features/localization_editor_navigation.qa.md @@ -85,7 +85,7 @@ Clock-sensitive: none expected. The board draws fixed times (Saved, 14:18); the ## Known gaps and scope decisions - loc.l11.session (the "Your session expired" modal) is not implemented: the existing redirect to /login?reason=expired stays. The visual checker records it as a known gap. -- The Add translation row in the language menu renders from capabilities but does nothing until localization_translation_flows. -- No if_revision is sent in this slice; the conflict dialog and the "Save over theirs" destructive cue belong to localization_translation_flows. +- The Add translation row in the language menu opens the Add a translation dialog (route /article/:id/edit?dialog=add-translation&lang=), as defined and tested by localization_translation_flows. +- if_revision is sent on every save of the open version since localization_translation_flows, together with the conflict dialog; the "Save over theirs" destructive cue stays a design gap recorded there. - features/editor_core.feature and features/editor_core.qa.md are amended in this slice for the editable title, the user and article scoped draft key, the Version unavailable copy and the "Save draft" label. -- The full-screen run of task visual:check for localization-editor passes only after localization_translation_flows; this slice checks state by state. +- The full-screen run of task visual:check for localization-editor includes the deletion states of localization_translation_flows (loc.l8.delete, deleteLast, deleteStoryOk, deleteStory); loc.l8.deleteSource stays pending. diff --git a/features/localization_translation_flows.feature b/features/localization_translation_flows.feature new file mode 100644 index 0000000..2d689a6 --- /dev/null +++ b/features/localization_translation_flows.feature @@ -0,0 +1,634 @@ +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":""}, GET /v1/article/?site= 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 + Then the response status is 201 and the body is the created article JSON + And + + 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 + When the client sends POST /v1/article/ with + Then the response status is + And the response body is + 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 + 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 and the URL is + When the editor has loaded + Then + + 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= 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 " · published" or " · 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 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" diff --git a/features/localization_translation_flows.qa.md b/features/localization_translation_flows.qa.md new file mode 100644 index 0000000..c1e7136 --- /dev/null +++ b/features/localization_translation_flows.qa.md @@ -0,0 +1,121 @@ +# QA: Translation flows, revision conflicts, delete scopes, article moves and a target site for new articles + +Accepted together with features/localization_translation_flows.feature. One pair, one pass: a Go backend part (POST /v1/article/ accepts `site`) comes first, then the SPA, which mocks `/v1` from the documented contract. Nothing else outside `web/` and `docs/` changes. + +## Backend (Go, `task test`, package `test`) + +Build `article.NewHandler(...)` over the real `CreateArticleUseCase`, `GetArticleUseCase`, `CreateTranslationUseCase`, `DescribeArticleUseCase` etc. wired to in-memory fakes (the existing `mockArticleRepo`, `mockSiteRepo` and language/localization/transaction fakes of `test/site_support_test.go` and `test/article_translation_create_http_test.go`; reuse, add no second copy). Mount it on a fresh `chi.NewRouter()` mirroring `api/router.go` for `/v1/article` (protected routes inside `authtoken.Verifier` + `authtoken.Authenticator` with a test token key and a cookie for each caller: writer, reader, anonymous), call `validator.InitValidator()`, exercise with `httptest`. No live server, no SQLite file, no SMTP. A new file `test/article_create_site_http_test.go` (unique fake type names) holds the HTTP cases; use-case cases go next to `test/article_create_language_test.go`. + +Fixture: sites journal (id 1, default; en default, fr, de, ar; es disabled), atelier (id 2; en, fr, de), notes (id 3; en), archive (id 4; en), fresh (id 5; no confirmed rows). Articles: story 5 with 10 (en), 11 (de), 12 (fr), 13 (ar) in journal. A fr article with slug `couts` in journal and in atelier for the slug cases. + +1. **Site decides placement, Examples-driven**: omitted `site`, `"site":null`, `"site":"journal"`, `"site":"atelier"` with and without `lang`; assert status 201, `site_id`, `lang` (starting language of the chosen site, never journal's), `is_online` false, new story, `created_by`, and that the response key set equals the pre-change key set (no new field). +2. **Refusals, Examples-driven over the table**: `missing` 404 `site_not_found` (plain text body, no JSON), `Bad Key!` and `""` 400 `invalid_site_key` (a short title keeps the validator text), `es` on atelier 422 `language_not_enabled`, `fr` on notes 422, `es` on journal 409 `language_unavailable`, `pt`, `xx`, `english`, short title, reader 403 `missing_permission`, anonymous 401 `Unauthorized`. Each: nothing stored in the fake. Refusals reaching the use case (404, 409, 422, short title) record one failed `article.create` event with a plain reason; refusals before it (400 validation of `site`, the site with story_id/source_article_id/copy 400, 401, 403) record no event, as before. +3. **Setup required**: site `fresh` with `lang` `fr` gives 409 `setup_required`; nothing stored. +4. **Language rules follow the site**: the same `{"lang":"fr"}` body is 201 on atelier and 409 `language_unavailable` on journal. +5. **Slug per (site, lang)**: clash in the same site and language gives the suffixed slug; the same slug in another site or another language leaves the plain slug. +6. **Site with a translation is refused**: the four bodies of the Examples table give 400 `apperr.BodyErrorMessage`, nothing stored, and the translation use case is not reached (assert with the fake). `POST /v1/article/10/translations` still creates in site 1 and ignores a `site` field. +7. **Visibility and activity**: the new offline article is 404 for an anonymous GET, absent from the public lists of both sites, listed by `GET /v1/article/?site=atelier` for a writer; the event params hold the site key. +8. **Dependency rule**: the handler test imports no persistence, models or gorm; a grep-style check is not added here (architect covers it). The handler change is the `Site *string` field with `validate:"omitempty,sitekey"` and the pass-through in `CreateArticleCommand`; no domain, repository, mapper or migration change. + +Real-database runs: `task test:mysql` and `task test:postgres` are not required, because no query, model or repository changes (existing `GetByKey`, `LockStartingLanguage`, `Rows`, `SlugTaken` and `Create` are already covered per site by `test/article_localization_storage_persistence_test.go` and `test/site_persistence_test.go`). If the implementation adds any query, add a persistence case there and run both. + +Existing Go specs to amend (and nothing else): `test/article_create_language_test.go` (command carries the site key), `test/article_translation_create_http_test.go` (rows of the "unchanged when it is not a translation" table, the `site` plus `story_id` refusal), `test/article_title_rules_test.go` and `test/use_case_baseline_test.go` only where they build `CreateArticleCommand` or `PostArticle` (compile and default-site behaviour must be unchanged), `test/api_catalogue_drift_test.go` stays green because the route list does not change (check it still passes; if the catalogue documents request fields, update it with the docs). `test/seo_data_cleaner_test.go` fails `GetDefault` on purpose and must keep passing for the omitted-site path. + +Docs (the HTTP contract the SPA mocks): update `docs/api/article-localization.md` (the `POST /v1/article/` without `story_id` section and its status table with the `site` rows above) and `docs/api/articles.md` (the `POST /v1/article/` request body), and add the `site` field to the create recipe in `docs/llm/recipe-articles.md`. The SPA mocks are written from these pages, so they land with the Go change, before the SPA work. + +## Automated SPA (Vitest + Vue Test Utils, `task test:web`) + +Mock `useApi`'s `request` per case (no backend), from the documented contract above. Mount EditorView in DashboardShell with a resolved verified session (user id 7, username "marta") and a test router with `/article/:id/edit` and `/article`; mount NewArticleModal from ArticleView and HomeView. Use fake timers for any delay. Specs live in `web/src/views/__tests__/`, `web/src/components/__tests__/`, `web/src/composables/__tests__/` and `web/src/lib/__tests__/`, reusing `editorKit.ts` and `localizationKit.ts`. + +Fixture: the 5a fixture (journal, story 5; article 10 en online "Renovation costs"; 12 fr offline "Couts de renovation"; 13 ar online "Takalif" read-only; 11 de readable by the owner) with `revision` added (12: 3). `GET /v1/site/` mocked with journal (default), atelier, notes, archive. `GET /v1/site/{key}/languages` mocked for journal (en default, fr, de, ar, es disabled), atelier (en, fr, de), notes (en), archive (en). Error mocks reject with `ApiError` carrying the plain-text body, `code` and the headers X-Existing-Article-Id, X-Current-Revision, X-Missing-Languages, X-Conflicting-Articles (the useApi change is tested first, item 1). + +Client plumbing +1. **useApi exposes the four response headers** on `ApiError` for non-ok answers, leaves `missing_permission` toasting by default and honours `onMissingPermission: 'passthrough'`; headers absent give `undefined`. +2. **Article type and draft**: `revision` read from GET and PATCH answers; the stored draft carries the base revision; a draft with no revision uses the revision loaded now (update `storedDraft`, `useArticleDraft` specs). + +Add translation +3. **Entry points, Examples-driven**: language menu row, URL with and without `lang`, `lang` of an existing or disabled language, no `create_translation` (parameters removed, no dialog), list row menu navigation (`dialog=add-translation`, `&lang=` only when one language is missing); Back closes (loc.l4.form). +4. **Offered languages**: enabled minus translations[]; an unreadable existing version is offered; order follows the site. +5. **Form copy, source default and Start empty**: description, help text, radios, footer order, default source rule (default language readable, else first readable), the source option labels, the empty state hiding the source (loc.l4.form, loc.l4.empty). +6. **Title validation, Examples-driven** (nothing, "ab", 201 chars, 200 emoji): no request when invalid. +7. **Request bodies**: copy with a chosen source, start empty (no `source_article_id`); path id is the open version. +8. **Creating**: fields disabled, "Creating…" busy, Escape and backdrop inert (loc.l4.saving). +9. **Success**: dialog closes, route `/article/14/edit`, announcement "German draft created"; with unsaved edits the "Leave without saving?" dialog opens with the German labels and "Keep editing" stays. +10. **Failures, Examples-driven over the table**: 409 language_exists (Open German version navigates through the version switch, loc.dup.readable), 409 language_unavailable with the languages refetched (still enabled gives loc.dup.hidden with no Open action, no title and no id; disabled gives loc.l4.langOff), 422 language_not_enabled, 404 source_unavailable (loc.l4.unreadable), 500 and network (loc.l4.fail, entries kept, "Try again"), 403 with no toast, 400, 404 Article not found (Version unavailable). Assert the three neutral answers render the same DOM. +11. **Recovery from blocking states** (source, start empty, language) re-enables the primary. +12. **Keyboard and focus**: trap, first field, Escape (not while creating), focus returns to the opener. +13. **Long content notes**: authors and images notes from the fetched source for the thresholds in the table; no note for one author and no image (loc.l4.long). + +New article modal +14. **Form by site, Examples-driven**: Archive (Title and Site only), Journal (Title, Site, Language, choice), languages request failing (Title and Site only, POST has `site` and no `lang`) (loc.l3.single, loc.l3.multi). +15. **Site field**: options in the order of `GET /v1/site/`, the `is_default` site chosen at open, one-site install still shows the field, sites request failing or empty hides it and POSTs `{title}` only. +16. **Site change** (loc.l3.sitechange): French on Journal then Notes gives English and the help "Notes only offers English, so the language was reset from French."; title kept; the help clears on the next change; Atelier keeps French with no message; languages request failing for the chosen site hides Language and the choice and POSTs `{title, site}`. +17. **Copy**: new description and "Create article"; the old sentence is gone; existing validation, counter, submitting, 400 and Error states of features/new_article_modal.feature unchanged. +18. **Create request**: body `{title, lang, site}` for Atelier and French, `{title, lang:"en", site:"journal"}` on the default site; failures: 409 `language_unavailable` and 422 `language_not_enabled` refetch the chosen site's languages and show the Error alert; 404 `site_not_found` refetches `GET /v1/site/`, resets to the default site and shows the Error alert; 409 `setup_required` and 403 `missing_permission` give the Error state with no global toast. +19. **Translation of an existing article**: `GET /v1/article/?tab=missing&lang=fr&q=...` mocked; no Site field in this mode; picker contents, help text, POST body with `story_id`, `source_article_id` (default-language version), `copy: true` and no `site`; failures reuse the Add translation alerts (loc.l3.translation). Switching the choice back and forth keeps the title and the site. + +Revisions and conflicts +20. **if_revision on every save path, Examples-driven**: Save, Save and publish (save step only), Save and open English, Try saving again, title Try again; publish and unpublish carry no body; base revision updates from each 200. +21. **Restored draft**: written against revision 3 while the server is at 5; Save sends 3 and the conflict dialog shows the Restored notice (loc.l11.conflictRestored). +22. **Conflict dialog**: PATCH 409 plus X-Current-Revision then GET; copy, option order, only "Keep my changes" filled, focus, Escape equals Keep, edits and unsaved state untouched (loc.conflict). Mobile sheet at 390 shows all four options. +23. **Chained actions stop**: Save and publish (no `/publish`), Save and open English (no GET 10). +24. **Conflict reload fails**: GET 500 or network shows Save failed, no dialog. +25. **Keep my changes**; **Download my version** (Blob content: title, blank line, body text; toast and announcement "Download started"; dialog stays; no request; loc.l11.download); **Reload theirs** two-step (loc.l11.reloadConfirm), discard clears dirty state, stored draft and sets the base revision; Keep from the confirm step. +26. **Save over theirs**: PATCH with the shown revision, no refetch before it, 200 closes the dialog and updates the base revision, no request for other articles. +27. **Second conflict** (loc.l11.conflict2): GET repeated, newer time 14:41, alert copy, revision updated; **other failures** (500, network, 401 mirror and redirect, 403, 404). + +Deletes +28. **Menu by scope, Examples-driven** over the three rows: enabled and disabled items, reasons, single version. +29. **Delete French version dialog and request** (loc.l8.delete): copy, danger button, busy state, DELETE 12 only, navigation to /article, toast; 404 closes quietly. +30. **Delete the only version** (loc.l8.deleteLast) and **delete the whole article** (loc.l8.deleteStoryOk): names in site language order, no hidden version, sequential DELETE (assert each call starts after the previous resolved) once per readable version with the open one last, navigation after the last 204. +31. **Refused whole delete** (loc.l8.deleteStory): first DELETE 403 gives the blocked dialog, no toast, "Delete French version" switches to the confirmation. **Stops half way**: the second DELETE answering 500, 403 or network error means the remaining versions are not requested, the toast "Couldn't delete the whole article" with "Some versions were deleted. Open the language menu to see what is left." shows, and the editor refetches or shows Version unavailable. A first-call failure other than 403 stops the same way. +32. **Single-language wording** unchanged. + +Move +33. **Panel and note**: opens from the Site field and from "Move article"; listbox "Site", current site marked, note rows with Published/Draft/Read only, "Move article…" disabled until another site; choosing a site does not enable Save or send site_id (loc.l11b.moveSelect). No sentence about unreadable versions and no footnote. +34. **Confirm, busy and done** (loc.l11b.moveConfirm, moveBusy, moveDone): request body `{site:"atelier"}` to `/v1/story/5/move`, locked dialog, toast and announcement "Moved to Atelier", silent refetch keeps unsaved edits, next Save uses the new revision. +35. **Refusals, Examples-driven over the table** (loc.l11b.move, moveSlug, movePerm): X-Missing-Languages names, the destination's languages from GET site languages, slug from GET article of each id in X-Conflicting-Articles (two ids give two sentences, no Edit slug button), 403 passes through without a global toast, 500/network, site_not_found, 404. +36. **Keyboard and focus** of the panel and its dialogs. + +Existing specs to amend (and nothing else) +37. Re-run and update `EditorView.spec.ts`, `EditorHeaderActions.spec.ts` (overflow items no longer "Not available yet." except Edit slug), `EditorLanguageMenu.spec.ts` (Add translation rows act), `EditorRecovery.spec.ts` (draft carries the revision), `EditorPublishing.spec.ts` (PATCH carries if_revision), `NewArticleModal.spec.ts` (title-only form becomes Title, Site, Language), the specs of `MetaSection` / sites_authors_integration (the Site field opens the move panel, the old choose-then-Save path is removed), `ArticleView` row menu (Add translation URL), `useArticleDraft*.spec.ts`. + +## Visual (`task visual:check`) + +Reference: docs/design/screens/localization-new-article.md, localization-editor.md and localization-conflicts-moves.md, boards in docs/design/source/. Register the screen `localization-new-article` in `web/visual/states.ts` (a new states file next to `editorLocalizationStates.ts`; it is not registered today), and replace the `pending` placeholders of the in-scope states below in `web/visual/editorLocalizationStates.ts`. Reuse the board's Journal, Archive, Atelier and Notes fixtures from `locale-fixtures.js` (article titles, the board's French title, Marta Alves, "Spring newsletter" for loc.l3.sitechange, the times 14:18, 14:32 and 14:41, the ten-author and eight-image source of loc.l4.long). Every capture must be within 10 %, light and dark, at the listed sizes. No exception or threshold change without the user's approval. + +Run state by state while iterating (`task visual:check -- localization-new-article loc.l4.form`, ...), the three screens whole once at the end. + +In scope (29), 1440 and 390: +- localization-new-article (13): loc.l3.single, loc.l3.multi, loc.l3.translation, loc.l3.sitechange, loc.l4.form, empty, saving, loc.dup.readable, loc.dup.hidden, loc.l4.fail, loc.l4.unreadable, loc.l4.langOff, loc.l4.long. +- localization-editor (4): loc.l8.delete, loc.l8.deleteLast, loc.l8.deleteStoryOk, loc.l8.deleteStory. +- localization-conflicts-moves (12): loc.conflict, loc.l11.conflictRestored, loc.l11.reloadConfirm, loc.l11.conflict2, loc.l11.download, loc.l11b.moveSelect, moveConfirm, moveBusy, moveDone, loc.l11b.move, moveSlug, movePerm. + +Skipped (4), each `skip: 'pending'` in the setups (the existing checker reason; they print SKIP and are not investigated): loc.l4.gone, loc.l8.deleteSource, loc.l11b.moveSelectHidden, loc.l11.session. Causes are in Known gaps. + +Clock-sensitive: none expected. The conflict times (14:32, 14:41, 14:18) come from mocked `updated_at` values and the stored mirror time, not from `now`. If a setup depends on the real clock, list it as `skip: 'clock-sensitive'`. + +## Manual + +- Focus order and focus return: dialogs and sheets return focus to their opener, the conflict dialog to the title, the Add translation dialog to the language-menu control. +- Screen reader: "German draft created", "Download started", "Moved to Atelier", dialogs announced as modal, Escape equals Keep my changes. +- Real round trip against `task dev`: create an article on a second site in another language and see it in the list of that site, add a translation (copy and empty) and open it, a real revision conflict from two browser sessions with all four choices, delete a version and the whole article, move a story to a second site with a missing language and with a slug clash, mobile sheets at 390 px. +- A real browser download of the conflict text file (Vitest checks the Blob only). +- Hover, pressed and motion states of the new dialogs; reduced motion. + +## Known gaps and scope decisions + +- "Save over theirs" has no destructive cue: it is drawn as an equal-weight secondary button next to "Reload theirs", as designed. Design gap to report to Claude Design; no cue is invented. +- Copy not in the design and proposed in the feature, to be confirmed by Claude Design: the toast "French version deleted", "Couldn't delete the whole article" with "Some versions were deleted. Open the language menu to see what is left.", "Couldn't move the article", "Couldn't replace theirs" for a non-conflict failure, and the Language help "Default for Atelier." pattern for any site. +- Skipped states and their cause: loc.l4.gone (a deleted source and an unreadable source give the same neutral 404, so loc.l4.unreadable covers both); loc.l8.deleteSource (siblings that track a version are unknown to the client; deferred); loc.l11b.moveSelectHidden (the client cannot tell a hidden version from a missing language; no footnote); loc.l11.session (the redirect to /login stays, the "Your session expired" dialog is not implemented). loc.l3.sitechange is now reachable through the backend `site` field and is checked. +- The Site field now also appears in the single-language form, as drawn (loc.l3.single shows Archive). Board copy kept verbatim, including the title help "0 / 200" difference with the existing "3 to 200 characters." text, recorded as a gap. +- The client cannot tell an unreadable sibling from a language with no version (the `missing_langs` follow-up on GET /v1/article/{id}), so Add translation is offered for every enabled language without a readable version and the neutral 409 answers the rest. +- "Delete the only version?" is shown when one version is readable, even if an unreadable one remains; the story then stays on the server. +- Deleting the whole article is one DELETE per readable version, sequential and stopping at the first failure other than 404 (a version already gone is skipped), not atomic. The stop-half-way toast is the only handling. +- content.create is not scoped to a site, so there is no "site the caller cannot create in" answer. A site deleted between the lookup and the insert surfaces as the existing 500 "Failed to create article". +- The copy text of the board mentions "Needs review" and "No source"; the freshness badge is release 2, so the words describe a state not yet visible. +- Edit slug: the "Edit slug" action of loc.l11b.moveSlug and the More actions item stay out until the Search and sharing slice. +- Article list row dialogs (Delete, Publish) are not changed in this slice. +- Amended in the same pair commit: features/article_translation_create.feature and .qa.md (the "unchanged when it is not a translation" table and wording, the `site` plus `story_id` refusal), features/localization_editor_navigation.feature and .qa.md (Background line on if_revision, overflow and Add translation scenarios, QA item 20, Known gaps), features/new_article_modal.feature and .qa.md (copy, Site, Language, translation path), features/sites_authors_integration.feature and .qa.md ("Changing the site is an edit"), features/articles_list.feature and .qa.md (Add translation destination), features/editor_core.feature and .qa.md (PATCH body carries if_revision; delete dialog). The Go specs listed under Backend are amended in the same commit. + +- Architect fix round: a refetch after a move or a partial delete is retried once; a second failure other than 404 announces "This page may be out of date. Reload it to see the latest." politely and keeps the edits. In the New article modal a 404 "Article not found" source shows the unreadable-source alert (neutral copy, no claim of deletion); Add translation keeps closing with Version unavailable. A bad `site` key answers 400 `invalid_site_key`. diff --git a/features/new_article_modal.feature b/features/new_article_modal.feature index 39f3375..1e2e497 100644 --- a/features/new_article_modal.feature +++ b/features/new_article_modal.feature @@ -11,10 +11,12 @@ Feature: Creating a new article from NewArticleModal button, desktop header button, and the empty state's "New article" action), per docs/design/routes.md and docs/design/screens/new-article.md; the Articles-page trigger and the /article/new deep link are out of scope for this slice, deferred until ArticlesPage exists - And every time it opens, it starts in the Default state: title "New article", text "It's - saved as a draft. Titles can't be changed later, so check it now.", an empty, focused Title + And every time it opens, it starts in the Default state: title "New article", text "Give the + article a title. You can write the rest in the editor." (superseding "It's saved as a draft. + Titles can't be changed later, so check it now."; the Site and Language fields and the + translation path are defined by localization_translation_flows), an empty, focused Title field with placeholder "e.g. Choosing tiles for a small bathroom", a CharacterCounter - reading "0 / 200" and help "3 to 200 characters.", a "Cancel" button and a "Create draft" + reading "0 / 200" and help "3 to 200 characters.", a "Cancel" button and a "Create article" button; there is no body or First paragraph field And the Title field is sent as the request's title and is validated required, 3 to 200 characters, counted the same way as the CharacterCounter ([...str].length, which is rune @@ -24,7 +26,7 @@ Feature: Creating a new article from NewArticleModal And the Title field validates on blur and again on submit; submitting while it is invalid sends no request: a title under 3 characters shows "Use at least 3 characters." in place of its help text, with the counter in its error style - And activating "Create draft" while the title is valid sends POST /v1/article/ with JSON + And activating "Create article" while the title is valid sends POST /v1/article/ with JSON {"title": "<title>"}; while the request is in flight the Title field and both buttons are disabled, the confirm button reads "Creating…" with a loading indicator, and the modal cannot be dismissed by Cancel, Esc or a backdrop click @@ -49,11 +51,11 @@ Feature: Creating a new article from NewArticleModal Scenario: Opening the modal shows the Default state Given the user is on /home When the user activates "New article" - Then NewArticleModal opens with title "New article" and the text "It's saved as a draft. - Titles can't be changed later, so check it now." + Then NewArticleModal opens with title "New article" and the text "Give the article a title. + You can write the rest in the editor." And the Title field is empty, focused, and its counter reads "0 / 200" And there is no First paragraph field - And "Create draft" is present but the form has not been submitted + And "Create article" is present but the form has not been submitted Scenario: Validation — a title under 3 characters on blur Given the modal is open @@ -64,13 +66,13 @@ Feature: Creating a new article from NewArticleModal Scenario: Validation — submitting an invalid title sends nothing Given the modal is open with Title "Ti" - When the user activates "Create draft" + When the user activates "Create article" Then the Title field shows "Use at least 3 characters." And POST /v1/article/ is never sent Scenario: Submitting disables the form and cannot be dismissed Given the modal is open with Title "Choosing tiles for a small bathroom" - When the user activates "Create draft" + When the user activates "Create article" Then POST /v1/article/ is sent with JSON {"title": "Choosing tiles for a small bathroom"} And the Title field becomes disabled, "Cancel" becomes disabled, and the confirm button reads "Creating…" with a loading indicator @@ -78,7 +80,7 @@ Feature: Creating a new article from NewArticleModal Scenario: A successful create closes the modal and hands off to the editor with a toast Given the user is on /home with the modal open, Title "Choosing tiles for a small bathroom", - and "Create draft" has been activated + and "Create article" has been activated When POST /v1/article/ resolves 201 with the created article, id 42, is_online false Then the modal closes And the app navigates to /article/42/edit @@ -88,14 +90,14 @@ Feature: Creating a new article from NewArticleModal Scenario: Title rejected — a 400 that got past this form's own check Given the modal is open with Title "Choosing tiles for a small bathroom" (already valid by - this form's own rule), and "Create draft" has been activated + this form's own rule), and "Create article" has been activated When POST /v1/article/ resolves 400 Then the modal re-enables, keeping the title And the Title field shows "Check this title and try again." in place of its help text And an alert reads "Something in the form isn't valid. Check the highlighted fields." Scenario Outline: Error — a 500 or a network failure keeps the text and offers to retry - Given the modal is open with Title "Choosing tiles for a small bathroom", and "Create draft" + Given the modal is open with Title "Choosing tiles for a small bathroom", and "Create article" has been activated When POST /v1/article/ <failure> Then the modal re-enables, keeping the title @@ -111,7 +113,7 @@ Feature: Creating a new article from NewArticleModal Scenario: Retrying after an Error succeeds Given the modal is in the Error state after a 500, with Title "Choosing tiles for a small bathroom" still in the field - When the user activates "Create draft" again + When the user activates "Create article" again Then POST /v1/article/ is sent again with the same JSON body When it resolves 201 with the created article, id 42 Then the modal closes, the app navigates to /article/42/edit, and a toast appears: color diff --git a/features/new_article_modal.qa.md b/features/new_article_modal.qa.md index 738e8da..9063d70 100644 --- a/features/new_article_modal.qa.md +++ b/features/new_article_modal.qa.md @@ -7,10 +7,9 @@ Only HomeView.vue is wired as a real `created` consumer in this slice: it listen the `/article/new` route stay pointed at the current ArticleView.vue placeholder until that page exists; don't wire a `created` handler there yet. -The modal is title-only: the First paragraph field is removed and the POST body is `{"title": "..."}` -with no `body` key (see features/article_body_unlimited.feature). +The First paragraph field is removed and the POST body carries no `body` key (see features/article_body_unlimited.feature). Since localization_translation_flows the modal also holds a Site field, a Language field and a Translation path: the body is `{"title": "..."}` only when GET /v1/site/ fails or is empty, otherwise it adds `site` (and `lang` when the Language field shows). -The confirm button's label is the design's "Create draft". +The confirm button's label is the design's "Create article". The Created toast uses `useToast().add({ id: 'article-created-{id}', color: 'success', icon: 'i-lucide-circle-check', title: 'Draft created', description: '"{title}" is saved as a draft.' })`, @@ -26,13 +25,13 @@ created article id 42, is_online false. 1. **Default state on open** — click "New article"; assert the modal shows title "New article", the intro text, an empty focused Title field with counter "0 / 200" and help "3 to 200 - characters.", "Cancel" and "Create draft", and no First paragraph field. + characters.", "Cancel" and "Create article", and no First paragraph field. 2. **Blur validation** — type "Ti" into Title and blur; assert the error "Use at least 3 characters." replaces the help text and the counter "2 / 200" is in its error style; assert no `request` call. -3. **Submit with an invalid title** — Title "Ti", click "Create draft"; assert the error shows and +3. **Submit with an invalid title** — Title "Ti", click "Create article"; assert the error shows and `request` is never called. -4. **Submitting state** — fill Title validly, click "Create draft"; assert `request` is called +4. **Submitting state** — fill Title validly, click "Create article"; assert `request` is called once with `POST /v1/article/` and the JSON body exactly `{"title": "Choosing tiles for a small bathroom"}`; assert Title and Cancel become disabled, the confirm button reads "Creating…"; assert pressing Escape and clicking the backdrop do not close the modal while unresolved. @@ -48,7 +47,7 @@ created article id 42, is_online false. `ApiError(500)`, (b) resolve it rejecting with `NetworkError`; in each, assert the title is kept, the alert reads "Couldn't create the article. Your text is still here. Try again in a moment.", and the field shows no error. -8. **Retry after Error succeeds** — from the Error state (500), click "Create draft" again; assert +8. **Retry after Error succeeds** — from the Error state (500), click "Create article" again; assert `request` is called a second time with the same JSON body; resolve 201 with the fixture; assert the modal closes, the router navigated to `/article/42/edit`, and the same "Draft created" toast (id "article-created-42") is raised. @@ -65,7 +64,7 @@ has no time-dependent content, so no fixed `now` is needed. The board frames may line in Submitting, Title rejected and Error; the design .md files are authoritative for the title-only modal and the boards are a known follow-up. -- [ ] Default: empty, focused Title with counter "0 / 200", help text, "Cancel" / "Create draft". +- [ ] Default: empty, focused Title with counter "0 / 200", help text, "Cancel" / "Create article". - [ ] Validation: Title "Ti" with counter "2 / 200" in error and "Use at least 3 characters." - [ ] Submitting: Title filled and disabled, confirm reads "Creating…" with its loading state. - [ ] Title rejected (400): Title filled, error "Check this title and try again.", alert @@ -73,7 +72,7 @@ title-only modal and the boards are a known follow-up. - [ ] Error: Title filled, alert "Couldn't create the article. Your text is still here. Try again in a moment.", no field-level error. - [ ] Created: no board frame; verified by Vitest (toast copy + navigation). -- [ ] Keyboard/focus: Tab reaches Title (already focused on open) → Cancel → Create draft; Esc +- [ ] Keyboard/focus: Tab reaches Title (already focused on open) → Cancel → Create article; Esc closes the modal in every dismissible state and is blocked while Submitting; the counter's live region announces only when its error threshold is crossed, not on every keystroke, per CharacterCounter's a11y spec. diff --git a/features/sites_authors_integration.feature b/features/sites_authors_integration.feature index 0c85dcd..d4f0f74 100644 --- a/features/sites_authors_integration.feature +++ b/features/sites_authors_integration.feature @@ -107,18 +107,16 @@ Feature: Sites and authors in the Editor, Articles and Profile screens And the helper text reads "Canonical host: journal.renew.example" And the select is available to every role that can edit the article - Scenario: Site select options - When the user opens the Site select - Then each option shows the site name and its host, and the default site is first + Scenario: Site field + When the user activates the Site field + Then the Move article panel opens with the sites in the order of GET /v1/site/ and the current site marked - Scenario: Changing the site is an edit + Scenario: Choosing a site no longer stages an edit (superseded by localization_translation_flows) Given the editor shows "Saved" and the article is on "Renew" - When the user chooses "Journal" - Then the status becomes "Unsaved changes" and Save is enabled - And no request is sent yet - When the user activates Save - Then exactly one PATCH /v1/article/12 is sent containing site_id 2 - And on 200 the status is "Saved" and the helper text shows the new host + When the user activates the Site field + Then the Move article panel of localization_translation_flows opens, with the sites listed + And choosing a site there does not change the status, does not enable Save and sends no site_id + And moving the article is the only way to change its site, through POST /v1/story/{story_id}/move Rule: Author list diff --git a/features/sites_authors_integration.qa.md b/features/sites_authors_integration.qa.md index 7450cbf..0f19f74 100644 --- a/features/sites_authors_integration.qa.md +++ b/features/sites_authors_integration.qa.md @@ -13,7 +13,7 @@ Mock `useApi`'s `request` per case for GET /v1/site/, GET /v1/author/, POST /v1/ Regression: every existing Editor, Articles, ArticlesPagination, ArticlesPageSizeMemory and Profile spec now sees the new calls. Give them default mocks (one site, one author, author null where relevant) rather than loosening unmocked-call checks, and show that they still pass. 1. MetaSection: collapsed summary "Inès Duarte"; open/closed Examples (sites x authors); remembered choice wins over auto-open; same key in the desktop sidebar and the mobile sheet; mobile closed in the Details sheet; loading skeleton; no section on 404; summary variants. -2. SiteSelect: absent with one site; present with helper "Canonical host: ..."; options with name and host and the default first; choosing marks "Unsaved changes"; the PATCH contains `site_id` and only the changed fields; GET /v1/site/ failure renders no select and no notice. +2. SiteMoveField: absent with one site; present with helper "Canonical host: ..."; activating it opens the Move article panel (the old SiteSelect, the staged `site_id` edit and its PATCH are removed, see localization_translation_flows); GET /v1/site/ failure renders no field and no notice. 3. AuthorList: Primary label, handle, remove, truncation with title and aria-label; keyboard reorder (grab, ArrowUp/Down, drop, Escape, live region text including dropped and cancelled); pointer reorder (use the sortable test helper; assert order and "Unsaved changes"); removing the last author shows the no-byline text; PATCH `author_ids` order, `[]` when emptied, absent when unchanged. 4. AuthorSelect: opens with GET /v1/author/ size 100; only sizes 5, 25, 50 or 100 are ever sent (other sizes answer 400); `q` search beyond the first 100; checked and dimmed items do nothing when activated; append on pick; "Create author…" per role (owner, admin, verified); no-match message with and without the create row; error state with "Try again". 5. Create author dialog: prefilled name and auto slug; POST body; appended to the list and "Unsaved changes"; the article's authors are not sent before Save; "This is me" only when author is null (default unchecked) and sends `user_id`; session store updated; slug taken and the "Use zoe-lambert-2" action; failure Examples; 403 toast. diff --git a/internal/domain/audit/auditDomain.go b/internal/domain/audit/auditDomain.go index 7bd186e..1b86e13 100644 --- a/internal/domain/audit/auditDomain.go +++ b/internal/domain/audit/auditDomain.go @@ -216,6 +216,7 @@ const ( ParamAssigned = "assigned" ParamLang = "lang" ParamStoryID = "story_id" + ParamSite = "site" ParamSourceArticleID = "source_article_id" maxNameLength = 255 maxActionLength = 64 diff --git a/internal/infrastructure/useCases/CreateArticle.go b/internal/infrastructure/useCases/CreateArticle.go index 6e785d0..cc6e98f 100644 --- a/internal/infrastructure/useCases/CreateArticle.go +++ b/internal/infrastructure/useCases/CreateArticle.go @@ -27,6 +27,7 @@ type CreateArticleCommand struct { Body string Format article.BodyFormat Lang string + Site *string Caller ContentCaller } @@ -34,12 +35,11 @@ func NewCreateArticleUseCase(transactor transaction.Transactor, bodyRenderer art return &CreateArticleUseCase{transactor: transactor, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors} } -func (g *CreateArticleUseCase) defaultSiteID() (uint32, error) { - defaultSite, err := g.sites.GetDefault() - if err != nil { - return 0, err +func (g *CreateArticleUseCase) targetSite(key *string) (site.Site, error) { + if key == nil { + return g.sites.GetDefault() } - return defaultSite.ID, nil + return g.sites.GetByKey(*key) } func (g *CreateArticleUseCase) creatorBylines(userID uint32) ([]author.Byline, error) { @@ -68,7 +68,11 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle return article.Article{}, err } - siteID, err := g.defaultSiteID() + target, err := g.targetSite(createArticle.Site) + if errors.Is(err, site.ErrSiteNotFound) { + failFrom(ctx, g.recorder, event, reasonFor(err)) + return article.Article{}, err + } if err != nil { recordFailure(ctx, g.recorder, event, err) return article.Article{}, err @@ -84,7 +88,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle Title: createArticle.Title, Body: createArticle.Body, IsOnline: false, - SiteID: siteID, + SiteID: target.ID, CreatedBy: createArticle.Caller.UserID, Authors: creditedAuthors, SEODismissed: []string{}, @@ -95,6 +99,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle } event = articleEvent(audit.ActionArticleCreate, created) + event.Params[audit.ParamSite] = target.Key g.recorder.Record(ctx, event) return renderArticle(g.bodyRenderer, created, createArticle.Format) } @@ -156,7 +161,3 @@ func chooseLanguage(repository localization.Repository, siteID uint32, requested } return requested, nil } - -func articleTarget(a article.Article) audit.Target { - return audit.NewTarget(audit.TargetArticle, a.ID, a.Title) -} diff --git a/internal/infrastructure/useCases/articleTarget.go b/internal/infrastructure/useCases/articleTarget.go new file mode 100644 index 0000000..7f2878d --- /dev/null +++ b/internal/infrastructure/useCases/articleTarget.go @@ -0,0 +1,10 @@ +package useCases + +import ( + "RenewCMS/internal/domain/article" + "RenewCMS/internal/domain/audit" +) + +func articleTarget(a article.Article) audit.Target { + return audit.NewTarget(audit.TargetArticle, a.ID, a.Title) +} diff --git a/internal/infrastructure/useCases/auditFailures.go b/internal/infrastructure/useCases/auditFailures.go index 6ca5695..888caee 100644 --- a/internal/infrastructure/useCases/auditFailures.go +++ b/internal/infrastructure/useCases/auditFailures.go @@ -79,6 +79,7 @@ var failureReasons = []failureReason{ {update.ErrNothingToUpdate, "There is nothing to update."}, {update.ErrPreflightRefused, "The update cannot be downloaded."}, {update.ErrRestartKindMismatch, "The requested restart does not match the server state."}, + {site.ErrSiteNotFound, "That site does not exist."}, {site.ErrSiteKeyTaken, "A site with that key already exists."}, {site.ErrKeyImmutable, "The key of a site cannot be changed."}, {site.ErrDefaultSite, "The default site cannot be deleted."}, diff --git a/test/article_create_language_test.go b/test/article_create_language_test.go index 6d37494..4888585 100644 --- a/test/article_create_language_test.go +++ b/test/article_create_language_test.go @@ -3,6 +3,7 @@ package test import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/language" + "RenewCMS/internal/domain/site" "RenewCMS/internal/infrastructure/useCases" "context" "errors" @@ -13,6 +14,9 @@ const ( createLanguageSiteID = 1 createLanguageTitle = "Bonjour le monde" confirmedDefaultTag = "fr-CA" + + createLanguageOtherSiteID = 2 + createLanguageOtherSiteKey = "atelier" ) func newCreateLanguageFixture(confirmed map[uint32]string) (*useCases.CreateArticleUseCase, *mockCreateTransactor, *mockArticleRepo) { @@ -76,4 +80,52 @@ func TestCreateArticleStartingLanguage(t *testing.T) { t.Errorf("expected %v without a create, got %v", failure, err) } }) + + t.Run("The command site decides the site and the language lock", func(t *testing.T) { + create, transactor, articles := newCreateLanguageFixture(map[uint32]string{createLanguageSiteID: confirmedDefaultTag, createLanguageOtherSiteID: "de"}) + create = useCases.NewCreateArticleUseCase(transactor, &fakeBodyRenderer{}, discardRecorder, createLanguageSites(), plainAuthors()) + command := createLanguageCommand() + command.Site = stringPointer(createLanguageOtherSiteKey) + + created, err := create.CreateArticle(context.Background(), command) + + stored := articles.articles[created.ID] + if err != nil || stored.SiteID != createLanguageOtherSiteID || stored.Lang != "de" { + t.Fatalf("expected a de article on site %d, got %+v %v", createLanguageOtherSiteID, stored, err) + } + if len(transactor.languages.locked) != 1 || transactor.languages.locked[0] != createLanguageOtherSiteID { + t.Errorf("the chosen site must be locked: %v", transactor.languages.locked) + } + }) + + t.Run("An unknown command site stores nothing", func(t *testing.T) { + create, transactor, articles := newCreateLanguageFixture(map[uint32]string{}) + create = useCases.NewCreateArticleUseCase(transactor, &fakeBodyRenderer{}, discardRecorder, createLanguageSites(), plainAuthors()) + command := createLanguageCommand() + command.Site = stringPointer("missing") + + _, err := create.CreateArticle(context.Background(), command) + + if !errors.Is(err, site.ErrSiteNotFound) || articles.createCalled || transactor.runs != 0 { + t.Errorf("expected site not found without a transaction, got %v", err) + } + }) + + t.Run("An omitted command site reads the default site", func(t *testing.T) { + sites := createLanguageSites() + create, transactor, articles := newCreateLanguageFixture(map[uint32]string{createLanguageSiteID: confirmedDefaultTag}) + create = useCases.NewCreateArticleUseCase(transactor, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors()) + + created, err := create.CreateArticle(context.Background(), createLanguageCommand()) + + if err != nil || articles.articles[created.ID].SiteID != createLanguageSiteID { + t.Errorf("expected the default site, got %+v %v", articles.articles[created.ID], err) + } + }) +} + +func createLanguageSites() *mockSiteRepo { + sites := plainSites() + sites.seed(createLanguageOtherSiteID, "Atelier", createLanguageOtherSiteKey, false) + return sites } diff --git a/test/article_create_site_http_test.go b/test/article_create_site_http_test.go new file mode 100644 index 0000000..f4f424e --- /dev/null +++ b/test/article_create_site_http_test.go @@ -0,0 +1,216 @@ +package test + +import ( + "RenewCMS/api/pkg/apperr" + "RenewCMS/internal/domain/article" + "RenewCMS/internal/domain/audit" + "net/http" + "reflect" + "sort" + "strings" + "testing" +) + +const ( + createSiteNotes uint32 = 3 + createSiteArchive uint32 = 4 + createSiteFresh uint32 = 5 + createSitePath = "/v1/article/" +) + +func newCreateSiteWorld(t *testing.T) *storyWorld { + t.Helper() + w := newTranslationWorld(t) + w.sites.seed(createSiteNotes, "Notes", "notes", false) + w.sites.seed(createSiteArchive, "Archive", "archive", false) + w.sites.seed(createSiteFresh, "Fresh", "fresh", false) + w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "de"}, nil) + w.rows.rows[createSiteNotes] = storyRows("en", []string{"en"}, nil) + w.rows.rows[createSiteArchive] = storyRows("en", []string{"en"}, nil) + w.transactor.starting = map[uint32]string{storyJournal: "en", storyAtelier: "fr", createSiteNotes: "en", createSiteArchive: "en"} + return w +} + +func fieldNames(response storyResponse, t *testing.T) []string { + t.Helper() + names := []string{} + for name := range response.fields(t) { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func TestArticleCreateSiteHTTP(t *testing.T) { + t.Run("The site decides where the new article is created", func(t *testing.T) { + cases := []struct { + body string + siteID uint32 + lang string + }{ + {`{"title":"Plain","body":"x"}`, storyJournal, "en"}, + {`{"title":"Plain","site":"journal"}`, storyJournal, "en"}, + {`{"title":"Neuf","lang":"fr","site":"atelier"}`, storyAtelier, "fr"}, + {`{"title":"Plain","site":"atelier"}`, storyAtelier, "fr"}, + {`{"title":"Plain","site":null}`, storyJournal, "en"}, + {`{"title":"Plain","site":"notes","lang":"en"}`, createSiteNotes, "en"}, + } + baseline := fieldNames(newCreateSiteWorld(t).send(storyWriter, http.MethodPost, createSitePath, `{"title":"Plain"}`), t) + for _, c := range cases { + t.Run(c.body, func(t *testing.T) { + w := newCreateSiteWorld(t) + before := w.snapshot() + + response := w.send(storyWriter, http.MethodPost, createSitePath, c.body) + + created := createdTranslation(t, w, response) + if created.SiteID != c.siteID || created.Lang != c.lang || created.IsOnline || created.CreatedBy != storyWriter || created.StoryID == storyStory { + t.Errorf("unexpected article: %+v", created) + } + if got := fieldNames(response, t); !reflect.DeepEqual(got, baseline) { + t.Errorf("the response keys changed: %v vs %v", got, baseline) + } + w.expectUnchanged(before, created.ID) + }) + } + }) + + t.Run("Refused target sites", func(t *testing.T) { + cases := []struct { + caller uint32 + body string + status int + message string + event bool + }{ + {storyWriter, `{"title":"Plain","site":"missing"}`, 404, apperr.SiteNotFoundErrorMessage, true}, + {storyWriter, `{"title":"Plain","site":"Bad Key!"}`, 400, apperr.InvalidSiteKeyErrorMessage, false}, + {storyWriter, `{"title":"Plain","site":""}`, 400, apperr.InvalidSiteKeyErrorMessage, false}, + {storyWriter, `{"title":"Plain","site":"atelier","lang":"es"}`, 422, apperr.LanguageNotEnabledErrorMessage, true}, + {storyWriter, `{"title":"Plain","site":"notes","lang":"fr"}`, 422, apperr.LanguageNotEnabledErrorMessage, true}, + {storyWriter, `{"title":"Plain","site":"journal","lang":"es"}`, 409, apperr.LanguageUnavailableErrorMessage, true}, + {storyWriter, `{"title":"Plain","site":"atelier","lang":"pt"}`, 422, apperr.LanguageNotEnabledErrorMessage, true}, + {storyWriter, `{"title":"Plain","site":"atelier","lang":"xx"}`, 422, apperr.UnsupportedLanguageErrorMessage, true}, + {storyWriter, `{"title":"Plain","site":"atelier","lang":"english"}`, 422, apperr.InvalidLanguageErrorMessage, true}, + {storyWriter, `{"title":"ab","site":"atelier"}`, 400, "", false}, + {storyReader, `{"title":"Plain","site":"atelier"}`, 403, apperr.MissingPermissionErrorMessage, false}, + {storyAnon, `{"title":"Plain","site":"atelier"}`, 401, "You must be logged in to access this resource.", false}, + {storyWriter, `{"title":"Plain","site":"fresh","lang":"fr"}`, 409, apperr.SetupRequiredErrorMessage, true}, + } + for _, c := range cases { + t.Run(c.body, func(t *testing.T) { + w := newCreateSiteWorld(t) + before := w.snapshot() + + response := w.send(c.caller, http.MethodPost, createSitePath, c.body) + + if response.Code != c.status || (c.message != "" && strings.TrimSpace(response.text()) != c.message) { + t.Fatalf("expected %d %q, got %d %q", c.status, c.message, response.Code, response.text()) + } + if strings.HasPrefix(strings.TrimSpace(response.text()), "{") { + t.Errorf("a plain text body expected: %q", response.text()) + } + w.expectUnchanged(before) + events := w.eventsOf(audit.ActionArticleCreate) + if c.event && (len(events) != 1 || events[0].Outcome != audit.OutcomeFailed || events[0].FailureReason == nil || *events[0].FailureReason == "") { + t.Errorf("one failed article.create event with a plain reason expected: %+v", events) + } + if !c.event && c.status != http.StatusForbidden && len(events) != 0 { + t.Errorf("no event expected: %+v", events) + } + }) + } + }) + + t.Run("The language rules are those of the chosen site", func(t *testing.T) { + w := newCreateSiteWorld(t) + + atelier := w.send(storyWriter, http.MethodPost, createSitePath, `{"title":"Neuf","lang":"fr","site":"atelier"}`) + journal := w.send(storyWriter, http.MethodPost, createSitePath, `{"title":"Neuf","lang":"es","site":"journal"}`) + + atelier.expect(t, http.StatusCreated, "") + journal.expect(t, http.StatusConflict, apperr.LanguageUnavailableErrorMessage) + }) + + t.Run("The slug is free within the chosen site and language", func(t *testing.T) { + w := newCreateSiteWorld(t) + w.put(article.Article{ID: 90, SiteID: storyJournal, Lang: "fr", Slug: "couts", Title: "Couts"}) + w.put(article.Article{ID: 91, SiteID: storyAtelier, Lang: "fr", Slug: "couts", Title: "Couts"}) + w.put(article.Article{ID: 92, SiteID: storyAtelier, Lang: "en", Slug: "frais", Title: "Frais"}) + + clash := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, createSitePath, `{"title":"Couts","lang":"fr","site":"atelier"}`)) + otherSite := w.send(storyWriter, http.MethodPost, createSitePath, `{"title":"Couts","lang":"en","site":"atelier"}`) + plainJournal := w.send(storyWriter, http.MethodPost, createSitePath, `{"title":"Frais","lang":"fr","site":"atelier"}`) + notClashing := w.send(storyWriter, http.MethodPost, createSitePath, `{"title":"Couts","lang":"en","site":"journal"}`) + + if clash.Slug != "couts-2" { + t.Errorf("expected couts-2, got %q", clash.Slug) + } + if got := createdTranslation(t, w, otherSite).Slug; got != "couts" { + t.Errorf("another language must keep the plain slug, got %q", got) + } + if got := createdTranslation(t, w, plainJournal).Slug; got != "frais" { + t.Errorf("another language must keep the plain slug, got %q", got) + } + if got := createdTranslation(t, w, notClashing).Slug; got != "couts" { + t.Errorf("another site must keep the plain slug, got %q", got) + } + }) + + t.Run("A site together with a translation is refused", func(t *testing.T) { + bodies := []string{ + `{"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}`, + } + for _, body := range bodies { + t.Run(body, func(t *testing.T) { + w := newCreateSiteWorld(t) + before := w.snapshot() + + response := w.send(storyWriter, http.MethodPost, createSitePath, body) + + response.expect(t, http.StatusBadRequest, apperr.BodyErrorMessage) + w.expectUnchanged(before) + if len(w.recorder.events) != 0 { + t.Errorf("the translation use case must not be reached") + } + }) + } + }) + + t.Run("A translation keeps the site of its source", func(t *testing.T) { + w := newCreateSiteWorld(t) + + response := w.send(storyWriter, http.MethodPost, translationsPath, `{"lang":"fr","title":"Couts","copy":true,"site":"atelier"}`) + + if created := createdTranslation(t, w, response); created.SiteID != storyJournal { + t.Errorf("the translation must stay in site 1: %+v", created) + } + }) + + t.Run("Visibility and activity", func(t *testing.T) { + w := newCreateSiteWorld(t) + + created := createdTranslation(t, w, w.send(storyWriter, http.MethodPost, createSitePath, `{"title":"Neuf","lang":"fr","site":"atelier"}`)) + + anonymous := w.send(storyAnon, http.MethodGet, createSitePath+idText(created.ID), "") + anonymous.expect(t, http.StatusNotFound, apperr.ArticleNotFoundErrorMessage) + for _, key := range []string{"journal", "atelier"} { + public := w.send(storyAnon, http.MethodGet, createSitePath+"?site="+key, "") + if strings.Contains(public.text(), `"Neuf"`) { + t.Errorf("the offline article must not be public on %s", key) + } + } + atelier := w.send(storyWriter, http.MethodGet, createSitePath+"?site=atelier", "") + journal := w.send(storyWriter, http.MethodGet, createSitePath+"?site=journal", "") + if !strings.Contains(atelier.text(), `"Neuf"`) || strings.Contains(journal.text(), `"Neuf"`) { + t.Errorf("only atelier lists the new article: %d %s / %d %s", atelier.Code, atelier.text(), journal.Code, journal.text()) + } + events := w.eventsOf(audit.ActionArticleCreate) + if len(events) != 1 || events[0].Outcome != audit.OutcomeSuccess || events[0].Params[audit.ParamSite] != "atelier" || events[0].Target.ID != idText(created.ID) { + t.Errorf("one success event with the site key expected: %+v", events) + } + }) +} diff --git a/test/article_translation_create_http_test.go b/test/article_translation_create_http_test.go index 523bdf6..9482623 100644 --- a/test/article_translation_create_http_test.go +++ b/test/article_translation_create_http_test.go @@ -312,6 +312,9 @@ func TestArticleTranslationCreateHTTP(t *testing.T) { {`{"title":"Neuf","story_id":5}`, 400, apperr.BodyErrorMessage, ""}, {`{"title":"Neuf","lang":"fr","story_id":999}`, 404, apperr.ArticleNotFoundErrorMessage, ""}, {`{"title":"Neuf","copy":true}`, 400, apperr.BodyErrorMessage, ""}, + {`{"title":"Neuf","lang":"fr","site":"atelier"}`, 201, "", "fr"}, + {`{"title":"Neuf","site":"missing"}`, 404, apperr.SiteNotFoundErrorMessage, ""}, + {`{"title":"Neuf","lang":"fr","story_id":5,"site":"atelier"}`, 400, apperr.BodyErrorMessage, ""}, } for _, c := range cases { t.Run(c.body, func(t *testing.T) { diff --git a/test/story_support_test.go b/test/story_support_test.go index 1424635..dca58a3 100644 --- a/test/story_support_test.go +++ b/test/story_support_test.go @@ -62,6 +62,7 @@ type storyTransactor struct { world *storyWorld runs int external map[uint32]article.Article + starting map[uint32]string } var _ transaction.Transactor = &storyTransactor{} @@ -72,7 +73,11 @@ func (s *storyTransactor) Run(ctx context.Context, work func(transaction.Reposit articles := maps.Clone(w.articles.articles) credits := maps.Clone(w.articles.credits) rows, _ := w.rows.snapshot() - err := work(transaction.Repositories{Articles: w.articles, Sites: w.sites, Authors: w.authors, Localization: w.rows, Languages: &mockStartingLanguages{confirmed: map[uint32]string{storyJournal: "en", storyAtelier: "en"}}}) + starting := map[uint32]string{storyJournal: "en", storyAtelier: "en"} + if s.starting != nil { + starting = s.starting + } + err := work(transaction.Repositories{Articles: w.articles, Sites: w.sites, Authors: w.authors, Localization: w.rows, Languages: &mockStartingLanguages{confirmed: starting}}) if err != nil { w.articles.articles = articles w.articles.credits = credits diff --git a/web/src/components/AddTranslationDialog.vue b/web/src/components/AddTranslationDialog.vue new file mode 100644 index 0000000..45a2e64 --- /dev/null +++ b/web/src/components/AddTranslationDialog.vue @@ -0,0 +1,345 @@ +<script setup lang="ts"> +import { locale, t } from '@/i18n' + +import { computed, nextTick, ref, watch } from 'vue' +import DirectoryField from '@/components/DirectoryField.vue' +import LocaleAlert from '@/components/localization/LocaleAlert.vue' +import LocaleChoiceCards from '@/components/localization/LocaleChoiceCards.vue' +import LocaleDialog from '@/components/localization/LocaleDialog.vue' +import LocaleInput from '@/components/localization/LocaleInput.vue' +import LocaleSelect from '@/components/localization/LocaleSelect.vue' +import TranslationFailureAlert from '@/components/localization/TranslationFailure.vue' +import { useApi } from '@/composables/useApi' +import type { Article } from '@/composables/useArticles' +import { existingArticleOf, classifyWithLanguages } from '@/lib/translationFailure' +import type { TranslationFailure } from '@/lib/translationFailure' +import { titleVerdict } from '@/lib/editorTitle' +import { languageName, type SiteLanguage } from '@/lib/siteLanguages' +import { + authorsNote, + defaultSource, + imagesNote, + offeredLanguages, + sourceLabel, +} from '@/lib/translationSource' + +export type AddTranslationResult = + | { kind: 'created'; article: Article; lang: string } + | { kind: 'open'; id: number; lang: string } + | { kind: 'gone' } + +const props = defineProps<{ + article: Article + languages: SiteLanguage[] + initialLang?: string + refetchLanguages: () => Promise<SiteLanguage[]> +}>() + +const emit = defineEmits<{ close: [result: AddTranslationResult | undefined] }>() + +type Mode = 'copy' | 'empty' +type Shown = Exclude<TranslationFailure, 'gone' | 'session'> + +const BLOCKING: Shown[] = ['exists', 'hidden', 'langOff', 'unreadable'] +const NOTE_FETCH_FAILED = null + +const { request } = useApi() + +const readable = computed(() => props.article.translations ?? []) +const languagesNow = ref(props.languages) +const offered = computed(() => offeredLanguages(languagesNow.value, readable.value)) +const lang = ref<string | undefined>( + props.initialLang && offered.value.includes(props.initialLang) ? props.initialLang : undefined, +) +const mode = ref<Mode>('copy') +const initialSource = defaultSource(languagesNow.value, readable.value) +const sourceId = ref<string | undefined>(initialSource ? String(initialSource.id) : undefined) +const title = ref('') +const titleError = ref('') +const pending = ref(false) +const failure = ref<Shown | null>(null) +const existingId = ref<number | undefined>(undefined) +const sourceDetails = ref<Article | null>(null) +const titleFieldWrap = ref<HTMLElement | null>(null) +const detailsCache = new Map<number, Article | null>() + +const targetName = computed(() => (lang.value ? languageName(lang.value, locale.value) : '')) +const source = computed(() => readable.value.find((entry) => String(entry.id) === sourceId.value)) +const lastSourceName = ref('') +const sourceName = computed(() => + source.value ? languageName(source.value.lang, locale.value) : lastSourceName.value, +) +watch( + source, + (entry) => { + if (entry) { + lastSourceName.value = languageName(entry.lang, locale.value) + } + }, + { immediate: true }, +) +const langItems = computed(() => { + const tags = + lang.value && !offered.value.includes(lang.value) + ? [...offered.value, lang.value] + : offered.value + return tags.map((tag) => ({ label: languageName(tag, locale.value), value: tag })) +}) +const sourceItems = computed(() => + readable.value.map((entry) => ({ label: sourceLabel(entry), value: String(entry.id) })), +) +const choiceItems = computed(() => [ + { + value: 'empty', + label: t('Start empty'), + description: t( + 'Only the title. Freshness shows No source until you pick a tracked source.', + ), + }, + { + value: 'copy', + label: t('Copy from {0}', [sourceName.value]), + description: t( + "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.", + ), + }, +]) +const showsDescription = computed(() => failure.value === null && !pending.value) +const description = computed(() => + !showsDescription.value + ? undefined + : lang.value + ? t('Creates a {0} draft of "{1}". It isn\'t published.', [ + targetName.value, + props.article.title, + ]) + : t('Creates a draft of "{0}" in the language you choose. It isn\'t published.', [ + props.article.title, + ]), +) +const titleLabel = computed(() => (lang.value ? t('Title in {0}', [targetName.value]) : t('Title'))) +const titleHelp = computed(() => + mode.value === 'copy' && sourceName.value + ? t('Copying text is a starting point: it stays in {0} until you translate it.', [ + sourceName.value, + ]) + : '', +) +const confirmLabel = computed(() => { + if (pending.value) { + return t('Creating…') + } + if (failure.value === 'failed') { + return t('Try again') + } + return lang.value ? t('Create {0} draft', [targetName.value]) : t('Create draft') +}) +const blocked = computed( + () => lang.value === undefined || (failure.value !== null && BLOCKING.includes(failure.value)), +) +const sourceMissing = computed(() => mode.value === 'copy' && sourceId.value === undefined) +const authors = computed(() => + mode.value === 'copy' ? authorsNote(sourceDetails.value?.authors ?? []) : null, +) +const images = computed(() => + mode.value === 'copy' && sourceDetails.value + ? imagesNote(sourceDetails.value, sourceName.value) + : null, +) +const placement = computed(() => { + if (!showsDescription.value) { + return 'sm:top-25 sm:max-h-169 sm:max-w-115' + } + return authors.value || images.value + ? 'sm:top-6 sm:max-h-207 sm:max-w-115' + : 'sm:top-15 sm:max-h-169 sm:max-w-115' +}) +const languageReadOnlyError = computed(() => + failure.value === 'langOff' ? t('{0} was disabled on this site.', [targetName.value]) : '', +) + +const openOptions = { + onOpenAutoFocus: (event: Event) => { + event.preventDefault() + nextTick(() => { + const field = lang.value + ? titleFieldWrap.value?.querySelector<HTMLElement>('input') + : document.querySelector<HTMLElement>('[role="combobox"]') + field?.focus() + }) + }, +} + +async function loadDetails() { + const id = source.value?.id + if (mode.value !== 'copy' || id === undefined) { + sourceDetails.value = null + return + } + if (id === props.article.id) { + sourceDetails.value = props.article + return + } + if (!detailsCache.has(id)) { + detailsCache.set( + id, + await request<Article>(`/v1/article/${id}?format=html`).catch(() => NOTE_FETCH_FAILED), + ) + } + sourceDetails.value = detailsCache.get(id) ?? null +} + +watch([sourceId, mode], loadDetails, { immediate: true }) + +watch(lang, () => { + if (failure.value && failure.value !== 'unreadable' && failure.value !== 'failed') { + failure.value = null + } + titleError.value = '' +}) + +watch([sourceId, mode], () => { + if ( + failure.value === 'unreadable' && + (mode.value === 'empty' || sourceId.value !== undefined) + ) { + failure.value = null + } +}) + +function bodyOf(): Record<string, unknown> { + const base = { lang: lang.value, title: title.value } + return mode.value === 'copy' + ? { ...base, copy: true, source_article_id: Number(sourceId.value) } + : { ...base, copy: false } +} + +async function reloadLanguages() { + languagesNow.value = await props.refetchLanguages() + return languagesNow.value +} + +async function submit() { + const verdict = titleVerdict(title.value) + if (verdict !== 'ok') { + titleError.value = { + empty: t('Enter a title'), + short: t('Use at least 3 characters'), + long: t('Use 200 characters or fewer'), + }[verdict] + return + } + titleError.value = '' + failure.value = null + pending.value = true + try { + const created = await request<Article>( + `/v1/article/${props.article.id}/translations?format=html`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(bodyOf()), + onMissingPermission: 'passthrough', + }, + ) + emit('close', { kind: 'created', article: created, lang: lang.value! }) + } catch (error) { + const kind = await classifyWithLanguages(error, lang.value, reloadLanguages) + if (kind === 'gone') { + emit('close', { kind: 'gone' }) + } else if (kind !== 'session') { + failure.value = kind + existingId.value = existingArticleOf(error) + if (kind === 'unreadable') { + sourceId.value = undefined + } + } + } finally { + pending.value = false + } +} + +function openExisting() { + if (existingId.value !== undefined && lang.value) { + emit('close', { kind: 'open', id: existingId.value, lang: lang.value }) + } +} +</script> + +<template> + <LocaleDialog + :title="t('Add a translation')" + :description="description" + :confirm-label="confirmLabel" + :pending="pending" + :confirm-disabled="blocked || sourceMissing" + :placement="placement" + :content="openOptions" + @close="emit('close', undefined)" + @confirm="submit" + > + <TranslationFailureAlert + v-if="failure" + :kind="failure" + :language="targetName" + :can-open="existingId !== undefined" + @open="openExisting" + /> + <DirectoryField :label="t('Language')" :error="languageReadOnlyError"> + <LocaleSelect + v-model="lang" + :items="langItems" + :aria-label="t('Language')" + :readonly="pending || failure === 'langOff'" + :shaded="failure === 'langOff'" + :invalid="failure === 'langOff'" + /> + </DirectoryField> + <DirectoryField + v-if="mode === 'copy'" + :label="t('Source version')" + :help=" + t( + 'You choose which version to copy from. It is also the source tracked for review. Access is checked again when you create the draft.', + ) + " + > + <LocaleSelect + v-model="sourceId" + :items="sourceItems" + :placeholder="t('Choose a version')" + :aria-label="t('Source version')" + :readonly="pending" + /> + </DirectoryField> + <div ref="titleFieldWrap"> + <DirectoryField :label="titleLabel" :error="titleError" :help="titleHelp"> + <LocaleInput v-model="title" :invalid="!!titleError" :readonly="pending" flat /> + </DirectoryField> + </div> + <div class="flex flex-col gap-2"> + <span class="font-medium text-highlighted">{{ t('Starting content') }}</span> + <LocaleChoiceCards + :model-value="mode" + @update:model-value="mode = $event as Mode" + :items="choiceItems" + :legend="t('Starting content')" + :disabled="pending" + /> + </div> + <LocaleAlert + v-if="authors" + tone="neutral" + icon="i-lucide-info" + :title="t('Authors')" + :description="authors" + /> + <LocaleAlert + v-if="images" + tone="neutral" + icon="i-lucide-info" + :title="t('Images')" + :description="images" + /> + </LocaleDialog> +</template> diff --git a/web/src/components/EditorConflictDialog.vue b/web/src/components/EditorConflictDialog.vue new file mode 100644 index 0000000..77e7341 --- /dev/null +++ b/web/src/components/EditorConflictDialog.vue @@ -0,0 +1,255 @@ +<script setup lang="ts"> +import { t } from '@/i18n' + +import { computed, nextTick, ref } from 'vue' +import { SHEET_CONTENT_CLASS } from '@/components/sheetClasses' +import LocaleAlert from '@/components/localization/LocaleAlert.vue' +import LocaleButton from '@/components/localization/LocaleButton.vue' +import type { Article } from '@/composables/useArticles' +import type { SaveOutcome } from '@/composables/useArticleDraft' +import { clockOf } from '@/lib/clockTime' + +export type ConflictResult = 'keep' | 'saved' | 'reloaded' | 'gone' | 'unauthorized' | 'forbidden' + +const props = defineProps<{ + theirs: Article + restoredAt: Date | null + fetchTheirs: () => Promise<Article | null> + attemptSave: (revision: number | undefined) => Promise<{ outcome: SaveOutcome; status: number }> + download: () => void + applyTheirs: (theirs: Article) => void +}>() + +const emit = defineEmits<{ close: [result: ConflictResult] }>() + +type Step = 'choose' | 'confirmReload' +type Failure = 'again' | 'failed' + +const CLOSE_BUTTON = { + color: 'neutral', + variant: 'ghost', + class: 'size-11 justify-center p-0 text-muted md:size-7', +} as const +const FORBIDDEN_STATUS = 403 + +const shown = ref(props.theirs) +const step = ref<Step>('choose') +const failure = ref<Failure | null>(null) +const pending = ref(false) +const keepButton = ref<{ $el: HTMLElement } | null>(null) + +const time = computed(() => clockOf(new Date(shown.value.updated_at))) +const title = computed(() => + step.value === 'choose' ? t('Someone else saved this version') : t('Reload theirs?'), +) +const description = computed(() => + step.value === 'choose' + ? t( + "Someone else saved this version at {0}. Your changes aren't saved. They stay here until you choose.", + [time.value], + ) + : t('This discards your unsaved changes.'), +) +const failureAlert = computed(() => { + if (failure.value === 'again') { + return { + title: t("Couldn't replace theirs"), + description: t( + 'Someone else saved this version again at {0}, before your save went through. Nothing was overwritten. Your changes are still here.', + [time.value], + ), + } + } + if (failure.value === 'failed') { + return { + title: t("Couldn't replace theirs"), + description: t('Nothing was overwritten. Your changes are still here. Try again.'), + } + } + return null +}) + +const openOptions = { + onOpenAutoFocus: (event: Event) => { + event.preventDefault() + nextTick(() => keepButton.value?.$el?.focus()) + }, + onCloseAutoFocus: (event: Event) => event.preventDefault(), +} + +async function saveOver() { + pending.value = true + failure.value = null + try { + const { outcome, status } = await props.attemptSave(shown.value.revision) + if (outcome === 'saved') { + emit('close', 'saved') + } else if (outcome === 'conflict') { + const newer = await props.fetchTheirs() + if (newer) { + shown.value = newer + } + failure.value = newer ? 'again' : 'failed' + } else if (outcome === 'not-found') { + emit('close', 'gone') + } else if (outcome === 'unauthorized') { + emit('close', 'unauthorized') + } else if (status === FORBIDDEN_STATUS) { + emit('close', 'forbidden') + } else { + failure.value = 'failed' + } + } finally { + pending.value = false + } +} + +function reload() { + props.applyTheirs(shown.value) + emit('close', 'reloaded') +} +</script> + +<template> + <UModal + :open="true" + :title="title" + :description="description" + :dismissible="!pending" + :close="pending ? false : CLOSE_BUTTON" + :content="openOptions" + :ui="{ + content: `${SHEET_CONTENT_CLASS} sm:top-15 sm:max-w-140`, + close: 'end-6 top-8 md:top-5', + header: 'min-h-0 items-start px-6 pt-5 pb-0 sm:px-6', + title: 'pe-14 text-lg/6 font-semibold md:pe-10', + description: 'mt-1 pe-14 text-sm text-pretty text-toned md:pe-10', + body: 'px-6 pt-4 pb-10 sm:px-6 sm:pt-4 sm:pb-10', + }" + @update:open="(value: boolean) => !value && !pending && emit('close', 'keep')" + > + <template #body> + <div class="flex flex-col gap-3.5"> + <LocaleAlert + v-if="failureAlert" + tone="error" + icon="i-lucide-circle-x" + :title="failureAlert.title" + :description="failureAlert.description" + /> + <LocaleAlert + v-else-if="restoredAt && step === 'choose'" + tone="info" + icon="i-lucide-info" + :title="t('Restored unsaved changes from {0}', [clockOf(restoredAt)])" + :description="t('Your session ended. These changes were kept in this browser.')" + /> + <div class="flex flex-col gap-3.5"> + <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4"> + <div class="flex min-w-0 flex-1 flex-col gap-0.5"> + <span class="font-semibold text-highlighted">{{ + t('Keep my changes') + }}</span> + <span class="text-sm/4.75 text-pretty text-muted">{{ + t('Nothing is sent. Your changes stay in the editor.') + }}</span> + </div> + <div class="flex sm:w-50 sm:shrink-0"> + <LocaleButton + ref="keepButton" + kind="primary" + block + :disabled="pending" + @click="emit('close', 'keep')" + > + {{ t('Keep my changes') }} + </LocaleButton> + </div> + </div> + <template v-if="step === 'choose'"> + <div + class="flex flex-col gap-2 border-t border-default pt-3.5 sm:flex-row sm:items-center sm:gap-4" + > + <div class="flex min-w-0 flex-1 flex-col gap-0.5"> + <span class="font-semibold text-highlighted">{{ + t('Download my version') + }}</span> + <span class="text-sm/4.75 text-pretty text-muted">{{ + t('Saves your title and body as a text file. Nothing is sent.') + }}</span> + </div> + <div class="flex sm:w-50 sm:shrink-0"> + <LocaleButton block :disabled="pending" @click="download()"> + {{ t('Download my version') }} + </LocaleButton> + </div> + </div> + <div + class="flex flex-col gap-2 border-t border-default pt-3.5 sm:flex-row sm:items-center sm:gap-4" + > + <div class="flex min-w-0 flex-1 flex-col gap-0.5"> + <span class="font-semibold text-highlighted">{{ + t('Reload theirs') + }}</span> + <span class="text-sm/4.75 text-pretty text-muted">{{ + t('Shows their version. You confirm first.') + }}</span> + </div> + <div class="flex sm:w-50 sm:shrink-0"> + <LocaleButton + block + :disabled="pending" + @click="step = 'confirmReload'" + > + {{ t('Reload theirs') }} + </LocaleButton> + </div> + </div> + <div + class="flex flex-col gap-2 border-t border-default pt-3.5 sm:flex-row sm:items-center sm:gap-4" + > + <div class="flex min-w-0 flex-1 flex-col gap-0.5"> + <span class="font-semibold text-highlighted">{{ + t('Save over theirs') + }}</span> + <span class="text-sm/4.75 text-pretty text-muted">{{ + t( + 'Replaces the title, body and search fields of this version only. Other languages are untouched.', + ) + }}</span> + </div> + <div class="flex sm:w-50 sm:shrink-0"> + <LocaleButton + block + :disabled="pending" + :loading="pending" + @click="saveOver" + > + {{ t('Save over theirs') }} + </LocaleButton> + </div> + </div> + </template> + <div + v-else + class="flex flex-col gap-2 border-t border-default pt-3.5 sm:flex-row sm:items-center sm:gap-4" + > + <div class="flex min-w-0 flex-1 flex-col gap-0.5"> + <span class="font-semibold text-highlighted">{{ + t('Reload theirs') + }}</span> + <span class="text-sm/4.75 text-pretty text-muted">{{ + t('This discards your unsaved changes.') + }}</span> + </div> + <div class="flex sm:w-50 sm:shrink-0"> + <LocaleButton block @click="reload"> + {{ t('Reload and discard my changes') }} + </LocaleButton> + </div> + </div> + </div> + </div> + </template> + </UModal> +</template> diff --git a/web/src/components/EditorTaskDialog.vue b/web/src/components/EditorTaskDialog.vue new file mode 100644 index 0000000..6994bb2 --- /dev/null +++ b/web/src/components/EditorTaskDialog.vue @@ -0,0 +1,51 @@ +<script setup lang="ts"> +import { ref } from 'vue' +import LocaleDialog from '@/components/localization/LocaleDialog.vue' + +export type TaskResult = + | { done: true; value?: unknown } + | { done: false; error: { title: string; description?: string } } + +const props = defineProps<{ + title: string + description: string + confirmLabel: string + cancelLabel?: string + tone?: 'primary' | 'danger' + run: () => Promise<TaskResult> +}>() + +const emit = defineEmits<{ close: [value: unknown] }>() + +const pending = ref(false) +const error = ref<{ title: string; description?: string } | null>(null) + +async function confirm() { + pending.value = true + error.value = null + try { + const result = await props.run() + if (result.done) { + emit('close', result.value ?? true) + } else { + error.value = result.error + } + } finally { + pending.value = false + } +} +</script> + +<template> + <LocaleDialog + :title="title" + :description="description" + :confirm-label="confirmLabel" + :cancel-label="cancelLabel" + :tone="tone" + :pending="pending" + :error="error" + @close="emit('close', undefined)" + @confirm="confirm" + /> +</template> diff --git a/web/src/components/MetaSection.vue b/web/src/components/MetaSection.vue index 8cc8323..1ac552e 100644 --- a/web/src/components/MetaSection.vue +++ b/web/src/components/MetaSection.vue @@ -6,7 +6,7 @@ import AuthorList from '@/components/AuthorList.vue' import AuthorSelect from '@/components/AuthorSelect.vue' import CreateAuthorDialog from '@/components/CreateAuthorDialog.vue' import CreateAuthorHint from '@/components/CreateAuthorHint.vue' -import SiteSelect from '@/components/SiteSelect.vue' +import SiteMoveField from '@/components/SiteMoveField.vue' import type { Byline } from '@/composables/useArticles' import type { Author } from '@/composables/useAuthors' import { useMyAuthorProfile } from '@/composables/useMyAuthorProfile' @@ -28,7 +28,7 @@ const props = withDefaults( ) const emit = defineEmits<{ - 'update:siteId': [siteId: number] + move: [] 'update:authors': [authors: Byline[]] 'update:remembered': [open: boolean] }>() @@ -116,11 +116,11 @@ function onCreated(author: Author) { @create="createMine" @dismiss="dismissHint" /> - <SiteSelect + <SiteMoveField v-if="severalSites" :sites="sites" - :model-value="siteId" - @update:model-value="(id) => id !== null && emit('update:siteId', id)" + :site-id="siteId" + @move="emit('move')" /> <AuthorList :authors="authors" diff --git a/web/src/components/MoveArticleDialog.vue b/web/src/components/MoveArticleDialog.vue new file mode 100644 index 0000000..9d59f67 --- /dev/null +++ b/web/src/components/MoveArticleDialog.vue @@ -0,0 +1,164 @@ +<script setup lang="ts"> +import { t } from '@/i18n' + +import { computed, ref } from 'vue' +import LocaleAlert from '@/components/localization/LocaleAlert.vue' +import LocaleDialog from '@/components/localization/LocaleDialog.vue' +import { joinNames } from '@/lib/nameList' +import type { MoveResult, MoveVersion } from '@/lib/moveArticle' + +const props = defineProps<{ + destination: string + origin: string + versions: MoveVersion[] + run: () => Promise<MoveResult> +}>() + +const emit = defineEmits<{ close: [moved: boolean | 'gone'] }>() + +type Stage = 'confirm' | 'moving' | Exclude<MoveResult['kind'], 'moved' | 'gone'> + +const stage = ref<Stage>('confirm') +const result = ref<MoveResult | null>(null) + +const moving = computed(() => stage.value === 'moving') +const refused = computed(() => !['confirm', 'moving'].includes(stage.value)) +const versionCount = computed(() => props.versions.length) +const title = computed(() => { + if (stage.value === 'perm') { + return t("You can't move this article") + } + return moving.value + ? t('Moving to {0}…', [props.destination]) + : t('Move to {0}?', [props.destination]) +}) +const description = computed(() => { + if (stage.value === 'perm') { + return undefined + } + if (moving.value) { + return versionCount.value > 1 + ? t('All {0} versions move together. Keep this page open.', [versionCount.value]) + : t('Keep this page open.') + } + return t('This moves every version together. Nothing is split.') +}) + +const failedTitle = computed(() => t("Couldn't move the article")) +const languagesAlert = computed(() => { + const current = result.value + if (current?.kind !== 'languages') { + return null + } + const missing = joinNames(current.missing) + const enabled = current.enabled + ? `${t('{0} enables {1} only.', [props.destination, joinNames(current.enabled)])} ` + : '' + return { + title: t("{0} doesn't enable every language in this article", [props.destination]), + description: + `${enabled}${t('Enable {0} on {1} first.', [missing, props.destination])} ` + + `${t('Languages disabled on {0} count too.', [props.origin])} ${t('Nothing was moved.')}`, + } +}) + +const slugAlert = computed(() => { + const current = result.value + if (current?.kind !== 'slug') { + return null + } + const sentences = current.conflicts.map((conflict) => + t('The {0} address "{1}" is already used by another {0} article on {2}.', [ + conflict.language, + conflict.slug, + props.destination, + ]), + ) + return { + title: t('An address is already used on {0}', [props.destination]), + description: [...sentences, t('Change it before moving. Nothing was moved.')].join(' '), + } +}) + +async function confirm() { + stage.value = 'moving' + const outcome = await props.run() + result.value = outcome + if (outcome.kind === 'moved') { + emit('close', true) + } else if (outcome.kind === 'gone') { + emit('close', 'gone') + } else { + stage.value = outcome.kind + } +} +</script> + +<template> + <LocaleDialog + :title="title" + :description="description" + :confirm-label="stage === 'perm' ? undefined : moving ? t('Moving…') : t('Move article')" + :cancel-label="stage === 'perm' ? t('Close') : undefined" + :pending="moving" + :confirm-disabled="refused" + @close="emit('close', false)" + @confirm="confirm" + > + <template v-if="stage === 'confirm'"> + <div v-if="versions.length > 0" class="flex flex-col gap-1.5"> + <span class="font-medium text-highlighted">{{ + t('Moves all language versions of this article.') + }}</span> + <span v-for="version in versions" :key="version.name" class="text-toned" + >{{ version.name }} · {{ version.status }}</span + > + </div> + <LocaleAlert + tone="neutral" + icon="i-lucide-info" + :title="t('Addresses and links')" + :description=" + t( + 'Each version keeps its address. Links from the old site to it will stop working. RenewCMS does not create redirects.', + ) + " + /> + </template> + <LocaleAlert + v-else-if="moving" + tone="info" + icon="i-lucide-loader-circle" + :title="t('Moving')" + :description="t('This takes a few seconds.')" + /> + <LocaleAlert + v-else-if="languagesAlert" + tone="warning" + icon="i-lucide-triangle-alert" + :title="languagesAlert.title" + :description="languagesAlert.description" + /> + <LocaleAlert + v-else-if="slugAlert" + tone="error" + icon="i-lucide-circle-x" + :title="slugAlert.title" + :description="slugAlert.description" + /> + <LocaleAlert + v-else-if="stage === 'perm'" + tone="error" + icon="i-lucide-circle-x" + :title="t('Moving needs edit permission on every version')" + :description="t('Ask an owner to move it. Nothing was changed.')" + /> + <LocaleAlert + v-else + tone="error" + icon="i-lucide-circle-x" + :title="failedTitle" + :description="t('Nothing was moved. Try again.')" + /> + </LocaleDialog> +</template> diff --git a/web/src/components/MoveArticlePanel.vue b/web/src/components/MoveArticlePanel.vue new file mode 100644 index 0000000..b18a7c5 --- /dev/null +++ b/web/src/components/MoveArticlePanel.vue @@ -0,0 +1,124 @@ +<script setup lang="ts"> +import { t } from '@/i18n' + +import { computed, nextTick, ref } from 'vue' +import { SHEET_MOBILE_CLASS } from '@/components/sheetClasses' +import LocaleButton from '@/components/localization/LocaleButton.vue' +import type { Site } from '@/composables/useSites' +import type { MoveVersion } from '@/lib/moveArticle' + +const props = defineProps<{ + sites: Site[] + currentSiteId: number | null + versions: MoveVersion[] +}>() + +const emit = defineEmits<{ close: [siteKey: string | undefined] }>() + +const chosenId = ref(props.currentSiteId) +const listbox = ref<HTMLElement | null>(null) + +const canMove = computed(() => chosenId.value !== null && chosenId.value !== props.currentSiteId) +const chosenKey = computed(() => props.sites.find((site) => site.id === chosenId.value)?.key) + +const openOptions = { + onOpenAutoFocus: (event: Event) => { + event.preventDefault() + nextTick(() => focusOption(props.sites.findIndex((site) => site.id === chosenId.value))) + }, +} + +function options() { + return Array.from(listbox.value?.querySelectorAll<HTMLElement>('[role="option"]') ?? []) +} + +function focusOption(index: number) { + const all = options() + all[Math.max(0, Math.min(index, all.length - 1))]?.focus() +} + +function onKeydown(event: KeyboardEvent) { + const index = options().indexOf(document.activeElement as HTMLElement) + const step: Record<string, number> = { ArrowDown: 1, ArrowUp: -1 } + if (event.key in step) { + event.preventDefault() + focusOption(index + step[event.key]!) + } +} + +function confirm() { + if (canMove.value) { + emit('close', chosenKey.value) + } +} +</script> + +<template> + <UModal + :open="true" + :title="t('Site')" + :description="t('Moves all language versions of this article.')" + :overlay="false" + :content="openOptions" + :ui="{ + content: `divide-y-0 shadow-(--shadow-popover) ring-1 ring-(--ui-border) sm:top-53.75 sm:right-6 sm:left-auto sm:max-w-85 sm:translate-x-0 sm:translate-y-0 ${SHEET_MOBILE_CLASS}`, + header: 'sr-only', + body: 'flex flex-col gap-3 p-4 sm:p-4', + }" + @update:open="(value: boolean) => !value && emit('close', undefined)" + > + <template #body> + <span class="font-semibold text-highlighted">{{ t('Site') }}</span> + <div + ref="listbox" + role="listbox" + :aria-label="t('Site')" + class="flex flex-col border border-default" + @keydown="onKeydown" + > + <button + v-for="site in sites" + :key="site.id" + type="button" + role="option" + :aria-selected="site.id === chosenId" + class="flex min-h-12 items-center gap-2 px-3 py-1.5 text-start sm:min-h-9" + :class=" + site.id === chosenId + ? 'bg-(--rc-sand) font-semibold text-(--rc-on-sand)' + : 'text-highlighted' + " + @click="chosenId = site.id" + > + <span class="w-4"> + <UIcon v-if="site.id === chosenId" name="i-lucide-check" class="size-4" /> + </span> + <span class="flex-1">{{ site.name }}</span> + <span + v-if="site.id === currentSiteId && site.id !== chosenId" + class="text-xs text-muted" + >{{ t('Current') }}</span + > + </button> + </div> + <div class="flex flex-col gap-1.5"> + <span class="font-medium text-highlighted">{{ + versions.length > 0 + ? t('Moves all language versions of this article.') + : t('Moves the article to the site you choose.') + }}</span> + <span v-for="version in versions" :key="version.name" class="text-toned" + >{{ version.name }} · {{ version.status }}</span + > + </div> + <div class="flex gap-2"> + <LocaleButton @click="emit('close', undefined)">{{ t('Cancel') }}</LocaleButton> + <div class="flex flex-1"> + <LocaleButton kind="primary" block :disabled="!canMove" @click="confirm"> + {{ t('Move article…') }} + </LocaleButton> + </div> + </div> + </template> + </UModal> +</template> diff --git a/web/src/components/NewArticleModal.vue b/web/src/components/NewArticleModal.vue index 8db4cff..7a10f55 100644 --- a/web/src/components/NewArticleModal.vue +++ b/web/src/components/NewArticleModal.vue @@ -1,19 +1,53 @@ <script setup lang="ts"> -import { t, translate } from '@/i18n' +import { locale, t, translate } from '@/i18n' -import { computed, nextTick, ref, watch } from 'vue' +import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' +import DirectoryField from '@/components/DirectoryField.vue' +import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses' +import LocaleChoiceCards from '@/components/localization/LocaleChoiceCards.vue' +import LocaleSelect from '@/components/localization/LocaleSelect.vue' +import TranslationFailureAlert from '@/components/localization/TranslationFailure.vue' import { ApiError, useApi } from '@/composables/useApi' -import type { Article } from '@/composables/useArticles' +import type { Article, StoryItem } from '@/composables/useArticles' +import { useArticles } from '@/composables/useArticles' +import { announce } from '@/composables/useLiveAnnouncement' +import { useNewArticleSites } from '@/composables/useNewArticleSites' +import { useSiteLanguages } from '@/composables/useSiteLanguages' +import { titleVerdict } from '@/lib/editorTitle' +import { languageAfterSiteChange } from '@/lib/languageAfterSiteChange' +import { codeOf, statusOf } from '@/lib/directoryErrors' +import { joinNames } from '@/lib/nameList' +import { languageName } from '@/lib/siteLanguages' +import { defaultSource, sourceLabel } from '@/lib/translationSource' +import { + classifyWithLanguages, + existingArticleOf, + type TranslationFailure, +} from '@/lib/translationFailure' const TITLE_MIN_LENGTH = 3 const TITLE_MAX_LENGTH = 200 const TITLE_PLACEHOLDER = 'e.g. Choosing tiles for a small bathroom' +const SEARCH_DELAY_MS = 250 +const CLOSE_BUTTON = { + color: 'neutral', + variant: 'ghost', + class: 'size-11 justify-center p-0 text-muted md:size-7', +} as const +const PICKER_SIZE = 20 + +type Kind = 'new' | 'translation' +type Shown = Exclude<TranslationFailure, 'gone' | 'session'> const open = defineModel<boolean>('open', { default: false }) -const emit = defineEmits<{ created: [article: Article] }>() +const emit = defineEmits<{ created: [article: Article]; open: [id: number] }>() const api = useApi() +const { getLanguages } = useSiteLanguages() +const { listStories } = useArticles() +const place = useNewArticleSites() +const { sites, siteKey, site, languages, defaultLang, lang, offered, multilingual } = place const title = ref('') const titleTouched = ref(false) @@ -21,11 +55,17 @@ const titleFocused = ref(false) const submitting = ref(false) const titleServerError = ref('') const formAlert = ref<'' | 'invalid' | 'failed'>('') +const kind = ref<Kind>('new') +const resetFrom = ref<string | null>(null) +const failure = ref<Shown | null>(null) +const existingId = ref<number | undefined>(undefined) +const searchTerm = ref('') +const stories = ref<StoryItem[]>([]) +const storyId = ref<number | undefined>(undefined) +let searchTimer: ReturnType<typeof setTimeout> | null = null const titleLength = computed(() => [...title.value].length) -const titleValid = computed( - () => titleLength.value >= TITLE_MIN_LENGTH && titleLength.value <= TITLE_MAX_LENGTH, -) +const titleValid = computed(() => titleVerdict(title.value) === 'ok') const titleLengthError = computed(() => { if (!titleTouched.value) { @@ -49,6 +89,79 @@ const titleCounterInvalid = computed( ) const showHelp = computed(() => !submitting.value && formAlert.value === '') +const translating = computed(() => kind.value === 'translation') +const showSite = computed(() => sites.value.length > 0 && !translating.value) +const showLanguage = computed( + () => languages.value !== null && (multilingual.value || resetFrom.value !== null), +) +const showChoice = computed(() => showLanguage.value) +const siteItems = computed(() => + sites.value.map((entry) => ({ label: entry.name, value: entry.key })), +) +const languageItems = computed(() => + offered.value.map((tag) => ({ label: languageName(tag, locale.value), value: tag })), +) +const targetName = computed(() => (lang.value ? languageName(lang.value, locale.value) : '')) +const choiceItems = computed(() => [ + { + value: 'new', + label: t('New article'), + description: t('Starts an article in this language.'), + }, + { + value: 'translation', + label: t('Translation of an existing article'), + description: t('Uses the same rules as Add translation.'), + }, +]) +const languageHelp = computed(() => { + if (resetFrom.value !== null) { + return t('{0} only offers {1}, so the language was reset from {2}.', [ + site.value?.name, + joinNames(offered.value.map((tag) => languageName(tag, locale.value))), + resetFrom.value, + ]) + } + return lang.value !== undefined && lang.value === defaultLang.value + ? t('Default for {0}.', [site.value?.name]) + : '' +}) +const chosenStory = computed(() => stories.value.find((item) => item.id === storyId.value)) +const chosenSource = computed(() => + chosenStory.value + ? defaultSource(languages.value ?? [], chosenStory.value.coverage ?? []) + : undefined, +) +const articleHelp = computed(() => + chosenSource.value && lang.value + ? t('No {0} version yet. Copies from {1}', [ + targetName.value, + sourceLabel(chosenSource.value), + ]) + : '', +) +const storyItems = computed(() => + stories.value.map((item) => ({ label: item.title, value: item.id })), +) +const titleLabel = computed(() => + translating.value && targetName.value ? t('Title in {0}', [targetName.value]) : t('Title'), +) +const description = computed(() => + translating.value + ? t('Create a translation of an article you can read.') + : t('Give the article a title. You can write the rest in the editor.'), +) +const confirmLabel = computed(() => { + if (submitting.value) { + return t('Creating…') + } + return translating.value && targetName.value + ? t('Create {0} draft', [targetName.value]) + : t('Create article') +}) +const confirmDisabled = computed( + () => submitting.value || (translating.value && chosenSource.value === undefined), +) const alertMessage = computed(() => { if (formAlert.value === 'invalid') { @@ -67,6 +180,13 @@ function resetForm() { submitting.value = false titleServerError.value = '' formAlert.value = '' + kind.value = 'new' + resetFrom.value = null + failure.value = null + existingId.value = undefined + searchTerm.value = '' + stories.value = [] + storyId.value = undefined } async function focusTitleField() { @@ -78,6 +198,12 @@ async function focusTitleField() { titleFocused.value = true } +async function loadPlace() { + await place.loadSites() + await place.loadLanguages() + lang.value = defaultLang.value ?? undefined +} + watch( open, (isOpen) => { @@ -86,6 +212,7 @@ watch( } resetForm() focusTitleField() + loadPlace() }, { immediate: true }, ) @@ -95,6 +222,171 @@ function onTitleBlur() { titleFocused.value = false } +async function onSiteChange(key: string | undefined) { + if (key === undefined || key === siteKey.value) { + return + } + const previous = lang.value + const wasShown = showLanguage.value + siteKey.value = key + resetFrom.value = null + await place.loadLanguages() + if (siteKey.value !== key) { + return + } + if (languages.value === null) { + lang.value = undefined + return + } + const next = languageAfterSiteChange(previous, offered.value, defaultLang.value) + lang.value = next.lang + if (next.replaced && wasShown) { + resetFrom.value = languageName(previous!, locale.value) + } +} + +function onLanguageChange(tag: string | undefined) { + lang.value = tag + resetFrom.value = null + if (failure.value && failure.value !== 'unreadable' && failure.value !== 'failed') { + failure.value = null + } + storyId.value = undefined + searchStories() +} + +function onKindChange(value: string | undefined) { + kind.value = value as Kind + failure.value = null + if (translating.value) { + searchStories() + } +} + +async function fetchStories() { + if (!translating.value || !lang.value) { + stories.value = [] + return + } + try { + const page = await listStories({ + page: 1, + size: PICKER_SIZE, + q: searchTerm.value.trim(), + lang: lang.value, + tab: 'missing', + site: siteKey.value, + }) + stories.value = page.items + } catch { + stories.value = [] + } +} + +function searchStories() { + if (searchTimer !== null) { + clearTimeout(searchTimer) + } + searchTimer = setTimeout(fetchStories, SEARCH_DELAY_MS) +} + +watch(searchTerm, searchStories) + +onBeforeUnmount(() => { + if (searchTimer !== null) { + clearTimeout(searchTimer) + } +}) + +function newArticleBody() { + const body: Record<string, unknown> = { title: title.value } + if (siteKey.value !== undefined && showSite.value) { + body.site = siteKey.value + if (showLanguage.value && lang.value) { + body.lang = lang.value + } + } + return body +} + +async function refreshAfterRefusal(error: ApiError) { + if (error.status === 404) { + await place.loadSites() + } + await place.loadLanguages() + lang.value = defaultLang.value ?? undefined + resetFrom.value = null +} + +function isRefusedPlace(error: unknown): error is ApiError { + const status = statusOf(error) + const code = codeOf(error) + return ( + (status === 409 && code === 'language_unavailable') || + (status === 422 && code === 'language_not_enabled') || + (status === 404 && code === 'site_not_found') + ) +} + +async function submitNew() { + try { + const article = await api.request<Article>('/v1/article/?format=html', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newArticleBody()), + onMissingPermission: 'passthrough', + }) + open.value = false + emit('created', article) + } catch (error) { + if (error instanceof ApiError && error.status === 400) { + titleServerError.value = t('Check this title and try again.') + formAlert.value = 'invalid' + return + } + formAlert.value = 'failed' + if (isRefusedPlace(error)) { + await refreshAfterRefusal(error) + } + } +} + +async function reloadLanguages() { + return (await getLanguages(siteKey.value ?? '')).languages +} + +async function submitTranslation() { + const story = chosenStory.value + const source = chosenSource.value + if (!story || !source || !lang.value) { + return + } + try { + const article = await api.request<Article>('/v1/article/?format=html', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: title.value, + lang: lang.value, + story_id: story.story_id, + source_article_id: source.id, + copy: true, + }), + onMissingPermission: 'passthrough', + }) + announce(t('{0} draft created', [targetName.value])) + open.value = false + emit('created', article) + } catch (error) { + const outcome = await classifyWithLanguages(error, lang.value, reloadLanguages) + if (outcome === 'session') { + return + } + failure.value = outcome === 'gone' ? 'unreadable' : outcome + existingId.value = existingArticleOf(error) + } +} + async function onSubmit() { titleTouched.value = true titleFocused.value = false @@ -105,27 +397,22 @@ async function onSubmit() { titleServerError.value = '' formAlert.value = '' + failure.value = null submitting.value = true try { - const article = await api.request<Article>('/v1/article/?format=html', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: title.value }), - }) - open.value = false - emit('created', article) - } catch (error) { - if (error instanceof ApiError && error.status === 400) { - titleServerError.value = 'Check this title and try again.' - formAlert.value = 'invalid' - } else { - formAlert.value = 'failed' - } + await (translating.value ? submitTranslation() : submitNew()) } finally { submitting.value = false } } +function openExisting() { + if (existingId.value !== undefined) { + open.value = false + emit('open', existingId.value) + } +} + function onCancel() { if (submitting.value) { return @@ -139,21 +426,21 @@ function onCancel() { <UModal v-model:open="open" :title="t('New article')" - :description="t('It\'s saved as a draft. Titles can\'t be changed later, so check it now.')" + :description="description" :dismissible="!submitting" + :close="submitting ? false : CLOSE_BUTTON" :ui="{ - content: - 'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-140 sm:translate-y-0 max-sm:!inset-x-0 max-sm:!top-auto max-sm:!bottom-0 max-sm:!left-0 max-sm:!w-auto max-sm:!max-w-none max-sm:before:absolute max-sm:before:top-2 max-sm:before:left-1/2 max-sm:before:h-1 max-sm:before:w-9 max-sm:before:-translate-x-1/2 max-sm:before:bg-accented max-sm:!translate-x-0 max-sm:!translate-y-0', - header: 'items-start gap-3 px-6 pt-7.5 pb-0 sm:px-6 sm:pt-5', - wrapper: 'pe-10 max-sm:pe-18', - close: 'max-sm:end-8 max-sm:top-9', - title: 'text-lg/6 font-semibold', - body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0', - footer: 'px-6 py-5 sm:px-6', + content: `${SHEET_CONTENT_CLASS} max-sm:max-h-11/12 sm:top-25 sm:max-h-169 sm:max-w-115`, + close: 'end-6 top-8 md:top-5', + header: 'min-h-16 items-start px-6 pt-5 pb-0 sm:min-h-12 sm:px-6', + title: 'pe-14 text-lg/6 font-semibold md:pe-10', + description: 'mt-1 pe-14 text-sm text-pretty text-toned md:pe-10', + body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0', + footer: SHEET_FOOTER_CLASS, }" > <template #body> - <div class="flex flex-col gap-4"> + <div class="flex flex-col gap-3.5"> <UAlert v-if="alertMessage" role="alert" @@ -164,13 +451,57 @@ function onCancel() { :ui="{ root: 'gap-2 px-3 py-2.5' }" :description="alertMessage" /> - <UForm :state="{ title }" class="flex flex-col gap-4"> + <TranslationFailureAlert + v-if="failure" + :kind="failure" + :language="targetName" + :can-open="existingId !== undefined" + @open="openExisting" + /> + <UForm :state="{ title }" class="flex flex-col gap-3.5"> + <template v-if="translating"> + <DirectoryField :label="t('Article')" :help="articleHelp"> + <USelectMenu + v-model="storyId" + v-model:search-term="searchTerm" + ignore-filter + :portal="false" + :content="{ position: 'inline' }" + :items="storyItems" + value-key="value" + :search-input="{ placeholder: t('Search articles') }" + trailing-icon="i-lucide-chevrons-up-down" + :placeholder="t('Choose an article')" + :disabled="submitting" + class="w-full" + :ui="{ + base: 'h-11 rounded-none text-sm ring-(--ui-border-accented) md:h-8', + trailingIcon: 'size-3.5 text-muted', + content: 'rounded-none shadow-(--shadow-popover)', + item: 'min-h-12 gap-2 px-2 py-1 data-[state=checked]:bg-elevated md:min-h-9', + }" + > + <template #empty>{{ + t('No article is missing this language.') + }}</template> + </USelectMenu> + </DirectoryField> + <DirectoryField :label="t('Language')"> + <LocaleSelect + :model-value="lang" + :items="languageItems" + :aria-label="t('Language')" + :readonly="submitting" + @update:model-value="onLanguageChange" + /> + </DirectoryField> + </template> <UFormField - :label="t('Title')" + :label="titleLabel" name="title" :error="translate(titleError) || undefined" > - <template #hint> + <template v-if="!translating" #hint> <span class="font-mono text-xs leading-tight" :class="titleCounterInvalid ? 'text-error' : 'text-muted'" @@ -186,7 +517,7 @@ function onCancel() { {{ t(error) }} </span> </template> - <template v-else-if="showHelp" #help>{{ + <template v-else-if="showHelp && !translating" #help>{{ t('3 to 200 characters.') }}</template> <UInput @@ -195,12 +526,45 @@ function onCancel() { :disabled="submitting" color="primary" :highlight="titleFocused || !!titleError" - :ui="{ base: 'h-11 text-lg md:h-10 md:text-base' }" + :ui="{ base: 'h-11 text-lg md:h-8 md:text-sm' }" class="w-full" @focus="titleFocused = true" @blur="onTitleBlur" /> </UFormField> + <template v-if="!translating"> + <DirectoryField v-if="showSite" :label="t('Site')"> + <LocaleSelect + :model-value="siteKey" + :items="siteItems" + :aria-label="t('Site')" + :readonly="submitting" + @update:model-value="onSiteChange" + /> + </DirectoryField> + <DirectoryField + v-if="showLanguage" + :label="t('Language')" + :help="languageHelp" + > + <LocaleSelect + :model-value="lang" + :items="languageItems" + :aria-label="t('Language')" + :readonly="submitting" + @update:model-value="onLanguageChange" + /> + </DirectoryField> + </template> + <LocaleChoiceCards + v-if="showChoice" + :model-value="kind" + :items="choiceItems" + :compact="translating" + :legend="t('New article or translation')" + :disabled="submitting" + @update:model-value="onKindChange" + /> </UForm> </div> </template> @@ -222,10 +586,10 @@ function onCancel() { :ui="{ base: 'h-11 md:h-8' }" class="sm:w-auto" :loading="submitting" - :disabled="submitting" + :disabled="confirmDisabled" @click="onSubmit" > - {{ submitting ? t('Creating…') : t('Create draft') }} + {{ confirmLabel }} </UButton> </div> </template> diff --git a/web/src/components/SiteMoveField.vue b/web/src/components/SiteMoveField.vue new file mode 100644 index 0000000..3f26a8e --- /dev/null +++ b/web/src/components/SiteMoveField.vue @@ -0,0 +1,41 @@ +<script setup lang="ts"> +import { t } from '@/i18n' + +import { computed } from 'vue' +import DirectoryField from '@/components/DirectoryField.vue' +import type { Site } from '@/composables/useSites' +import { hostOf } from '@/lib/directory' + +const props = defineProps<{ sites: Site[]; siteId: number | null }>() + +const emit = defineEmits<{ move: [] }>() + +const chosen = computed(() => props.sites.find((site) => site.id === props.siteId)) +const chosenHost = computed(() => (chosen.value ? hostOf(chosen.value.base_url) : '')) +</script> + +<template> + <div class="flex flex-col gap-1.5"> + <DirectoryField :label="t('Site')"> + <button + type="button" + aria-haspopup="dialog" + :aria-label="t('Site')" + class="flex h-11 w-full items-center justify-between gap-2 border border-accented bg-default px-2.5 text-start text-lg lg:h-10 lg:text-sm" + @click="emit('move')" + > + <span v-if="chosen" class="truncate text-highlighted" + >{{ chosen.name }} · {{ chosenHost }}</span + > + <UIcon name="i-lucide-chevrons-up-down" class="size-3.5 shrink-0 text-muted" /> + </button> + </DirectoryField> + <p v-if="chosen" class="flex items-center gap-1.5 text-xs/4.5 text-muted"> + <UIcon name="i-lucide-link" class="size-3 shrink-0" /> + <span + >{{ t('Canonical host:') }} + <span class="font-mono text-toned">{{ chosenHost }}</span></span + > + </p> + </div> +</template> diff --git a/web/src/components/__tests__/NewArticleModal.spec.ts b/web/src/components/__tests__/NewArticleModal.spec.ts index 86c7ca2..460e965 100644 --- a/web/src/components/__tests__/NewArticleModal.spec.ts +++ b/web/src/components/__tests__/NewArticleModal.spec.ts @@ -55,6 +55,12 @@ async function clickButton(text: string) { let requestMock: ReturnType<typeof vi.fn> +function postCalls() { + return requestMock.mock.calls.filter( + (call) => (call[1] as RequestInit | undefined)?.method === 'POST', + ) +} + function mountModal() { requestMock = vi.fn() vi.mocked(useApi).mockReturnValue({ @@ -104,8 +110,9 @@ describe('Opening the modal shows the Default state', () => { expect(document.body.textContent).toContain('New article') expect(document.body.textContent).toContain( - "It's saved as a draft. Titles can't be changed later, so check it now.", + 'Give the article a title. You can write the rest in the editor.', ) + expect(document.body.textContent).not.toContain("Titles can't be changed later") expect(titleInput().value).toBe('') expect(document.activeElement).toBe(titleInput()) @@ -116,8 +123,8 @@ describe('Opening the modal shows the Default state', () => { expect(document.querySelector('textarea')).toBeNull() expect(findButton('Cancel')).toBeTruthy() - expect(findButton('Create draft')).toBeTruthy() - expect(requestMock).not.toHaveBeenCalled() + expect(findButton('Create article')).toBeTruthy() + expect(postCalls()).toHaveLength(0) }) }) @@ -132,7 +139,7 @@ describe('Validation — a title under 3 characters on blur', () => { expect(document.body.textContent).toContain('Use at least 3 characters.') expect(document.body.textContent).not.toContain('3 to 200 characters.') expect(document.body.textContent).toContain('2 / 200') - expect(requestMock).not.toHaveBeenCalled() + expect(postCalls()).toHaveLength(0) }) }) @@ -142,10 +149,10 @@ describe('Validation — submitting with an invalid title shows the error and se await flushPromises() await fillTitle('Ti') - await clickButton('Create draft') + await clickButton('Create article') expect(document.body.textContent).toContain('Use at least 3 characters.') - expect(requestMock).not.toHaveBeenCalled() + expect(postCalls()).toHaveLength(0) }) }) @@ -169,7 +176,7 @@ describe('Submitting disables the form and cannot be dismissed', () => { await flushPromises() await fillValidForm() - await clickButton('Create draft') + await clickButton('Create article') expect(requestMock).toHaveBeenCalledWith( '/v1/article/?format=html', @@ -216,7 +223,7 @@ describe('A successful create closes the modal and hands off to the editor with await flushPromises() await fillValidForm() - await clickButton('Create draft') + await clickButton('Create article') await flushPromises() expect(wrapper.emitted('created')).toEqual([[articleFixture()]]) @@ -239,7 +246,7 @@ describe("Title rejected — a 400 that got past this form's own check", () => { await flushPromises() await fillValidForm() - await clickButton('Create draft') + await clickButton('Create article') await flushPromises() expect(titleInput().value).toBe(TITLE) @@ -269,7 +276,7 @@ describe('Error — a 500 or a network failure keeps the text and offers to retr await flushPromises() await fillValidForm() - await clickButton('Create draft') + await clickButton('Create article') await flushPromises() expect(titleInput().value).toBe(TITLE) @@ -283,7 +290,17 @@ describe('Error — a 500 or a network failure keeps the text and offers to retr describe('Retrying after an Error succeeds', () => { it('sends the same body again and hands off to the editor on success', async () => { - requestMock = vi.fn().mockRejectedValueOnce(new ApiError(500)) + let posts = 0 + requestMock = vi.fn(async (_path: string, init?: RequestInit) => { + if (init?.method !== 'POST') { + return [] + } + posts += 1 + if (posts === 1) { + throw new ApiError(500) + } + return articleFixture() + }) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock, @@ -295,21 +312,15 @@ describe('Retrying after an Error succeeds', () => { await flushPromises() await fillValidForm() - await clickButton('Create draft') + await clickButton('Create article') await flushPromises() - requestMock.mockResolvedValueOnce(articleFixture()) - await clickButton('Create draft') + await clickButton('Create article') await flushPromises() - expect(requestMock).toHaveBeenCalledTimes(2) - expect(requestMock).toHaveBeenNthCalledWith( - 2, - '/v1/article/?format=html', - expect.objectContaining({ - body: JSON.stringify({ title: TITLE }), - }), - ) + expect(postCalls()).toHaveLength(2) + expect(postCalls()[1]![0]).toBe('/v1/article/?format=html') + expect((postCalls()[1]![1] as RequestInit).body).toBe(JSON.stringify({ title: TITLE })) expect(wrapper.emitted('created')).toEqual([[articleFixture()]]) expect(document.querySelector('[role="dialog"]')).toBeFalsy() wrapper.unmount() @@ -325,6 +336,6 @@ describe('Cancel discards the fields with no confirmation', () => { await clickButton('Cancel') expect(document.querySelector('[role="dialog"]')).toBeFalsy() - expect(requestMock).not.toHaveBeenCalled() + expect(postCalls()).toHaveLength(0) }) }) diff --git a/web/src/components/__tests__/NewArticleModalSites.spec.ts b/web/src/components/__tests__/NewArticleModalSites.spec.ts new file mode 100644 index 0000000..cd977d7 --- /dev/null +++ b/web/src/components/__tests__/NewArticleModalSites.spec.ts @@ -0,0 +1,508 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, ref } from 'vue' +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import uiPlugin from '@nuxt/ui/vue-plugin' +import SelectMenu from '@nuxt/ui/components/SelectMenu.vue' +import { ApiError, NetworkError, useApi } from '@/composables/useApi' +import type { Article } from '@/composables/useArticles' +import LocaleSelect from '@/components/localization/LocaleSelect.vue' +import { initializeLocale } from '@/i18n' +import NewArticleModal from '../NewArticleModal.vue' + +vi.mock('@/composables/useApi', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/composables/useApi')>() + return { ...actual, useApi: vi.fn() } +}) + +const TITLE = 'Spring newsletter' + +const SITES = [ + { id: 1, key: 'journal', name: 'Journal', is_default: true }, + { id: 2, key: 'atelier', name: 'Atelier', is_default: false }, + { id: 3, key: 'notes', name: 'Notes', is_default: false }, + { id: 4, key: 'archive', name: 'Archive', is_default: false }, +] + +const LANGUAGES: Record<string, string[]> = { + journal: ['en', 'fr', 'de', 'ar'], + atelier: ['en', 'fr', 'de'], + notes: ['en'], + archive: ['en'], +} + +function languagesBody(key: string) { + return { + revision: `${key}-1`, + default: 'en', + needs_setup: false, + available: [], + languages: (LANGUAGES[key] ?? []).map((lang) => ({ + lang, + enabled: true, + is_default: lang === 'en', + version_count: 0, + published_count: 0, + social_locale: null, + social_locale_state: 'not_set', + })), + } +} + +function story(id: number, storyId: number, title: string) { + return { + id, + story_id: storyId, + title, + coverage: [ + { + id, + lang: 'en', + title, + is_online: true, + language_enabled: true, + capabilities: ['read'], + }, + ], + capabilities: ['read', 'create_translation'], + missing_langs: ['fr'], + } +} + +const created = { id: 42, title: TITLE, body: '', is_online: false } as unknown as Article + +type Handler = (path: string, init?: RequestInit) => unknown + +let routes: Record<string, Handler> +let calls: { method: string; path: string; body: unknown }[] +let wrapper: VueWrapper + +function route(key: string, handler: Handler) { + routes[key] = handler +} + +const Host = defineComponent({ + components: { NewArticleModal }, + emits: ['created', 'open'], + setup(_props, { emit }) { + const open = ref(true) + return { open, emit } + }, + template: `<NewArticleModal v-model:open="open" @created="emit('created', $event)" @open="emit('open', $event)" />`, +}) + +async function mountModal() { + vi.mocked(useApi).mockReturnValue({ + getMe: vi.fn(), + request: vi.fn(async (path: string, init?: RequestInit) => { + const [pathname = ''] = path.split('?') + const method = init?.method ?? 'GET' + calls.push({ + method, + path: pathname, + body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, + }) + const handler = routes[`${method} ${pathname}`] + if (!handler) { + throw new Error(`Unexpected request: ${method} ${pathname}`) + } + return handler(path, init) + }), + } as unknown as ReturnType<typeof useApi>) + wrapper = mount(Host, { attachTo: document.body, global: { plugins: [uiPlugin] } }) + await flushPromises() + await flushPromises() +} + +function select(label: string) { + return wrapper + .findAllComponents(LocaleSelect) + .find((component) => component.props('ariaLabel') === label) +} + +async function choose(label: string, value: string) { + select(label)!.vm.$emit('update:modelValue', value) + await flushPromises() + await flushPromises() +} + +function text() { + return (document.body.textContent ?? '').replace(/\s+/g, ' ') +} + +function titleInput() { + return document.body.querySelector<HTMLInputElement>( + 'input[placeholder="e.g. Choosing tiles for a small bathroom"]', + )! +} + +async function type(value: string) { + const input = titleInput() + input.value = value + input.dispatchEvent(new Event('input', { bubbles: true })) + await flushPromises() +} + +function button(label: string) { + return Array.from(document.body.querySelectorAll('button')).find( + (element) => element.textContent?.trim() === label, + ) +} + +async function press(label: string) { + button(label)!.click() + await flushPromises() + await flushPromises() +} + +function posts() { + return calls.filter((call) => call.method === 'POST') +} + +beforeEach(() => { + initializeLocale() + calls = [] + routes = { + 'GET /v1/site/': () => structuredClone(SITES), + 'POST /v1/article/': () => created, + } + for (const key of Object.keys(LANGUAGES)) { + route(`GET /v1/site/${key}/languages`, () => languagesBody(key)) + } +}) + +afterEach(() => { + wrapper.unmount() + document.body.innerHTML = '' + vi.useRealTimers() +}) + +describe('loc.l3.single and loc.l3.multi: the form follows the Site field', () => { + it('shows Title, Site and Language with the default help and the choice for Journal', async () => { + await mountModal() + + expect(select('Site')!.props('modelValue')).toBe('journal') + expect(select('Language')!.props('modelValue')).toBe('en') + expect(text()).toContain('Default for Journal.') + expect(text()).toContain('Starts an article in this language.') + expect(text()).toContain('Uses the same rules as Add translation.') + }) + + it('shows Title and Site only for a single-language site', async () => { + await mountModal() + await choose('Site', 'archive') + + expect(select('Site')!.props('modelValue')).toBe('archive') + expect(select('Language')).toBeUndefined() + expect(text()).not.toContain('Translation of an existing article') + }) + + it('hides Language and the choice and posts the site without lang when the languages request fails', async () => { + route('GET /v1/site/journal/languages', () => { + throw new NetworkError() + }) + await mountModal() + await type(TITLE) + await press('Create article') + + expect(select('Language')).toBeUndefined() + expect(text()).not.toContain('Translation of an existing article') + expect(posts()[0]!.body).toEqual({ title: TITLE, site: 'journal' }) + }) +}) + +describe('The Site field lists the sites and starts on the default site', () => { + it('lists the sites in the order of the response with Journal chosen', async () => { + await mountModal() + + expect(select('Site')!.props('items')).toEqual([ + { label: 'Journal', value: 'journal' }, + { label: 'Atelier', value: 'atelier' }, + { label: 'Notes', value: 'notes' }, + { label: 'Archive', value: 'archive' }, + ]) + expect(select('Site')!.props('modelValue')).toBe('journal') + }) + + it('still shows the field on a one-site install', async () => { + route('GET /v1/site/', () => [ + SITES[3]!.id === 4 ? { ...SITES[3]!, is_default: true } : SITES[3], + ]) + await mountModal() + + expect(select('Site')!.props('items')).toEqual([{ label: 'Archive', value: 'archive' }]) + expect(select('Site')!.props('modelValue')).toBe('archive') + }) + + it.each([ + [ + 'fails', + () => { + throw new NetworkError() + }, + ], + ['is empty', () => []], + ])('creates with the title only when the sites request %s', async (_name, handler) => { + route('GET /v1/site/', handler) + await mountModal() + await type(TITLE) + + expect(select('Site')).toBeUndefined() + expect(select('Language')).toBeUndefined() + await press('Create article') + expect(posts()[0]!.body).toEqual({ title: TITLE }) + }) +}) + +describe('loc.l3.sitechange: Changing the site', () => { + it('resets a language the new site does not offer and explains it', async () => { + await mountModal() + await type(TITLE) + await choose('Language', 'fr') + await choose('Site', 'notes') + + expect(select('Language')!.props('modelValue')).toBe('en') + expect(text()).toContain( + 'Notes only offers English, so the language was reset from French.', + ) + expect(titleInput().value).toBe(TITLE) + expect(text()).toContain('Translation of an existing article') + }) + + it('ignores a slow answer of an earlier site once a later site is chosen', async () => { + let release!: (value: unknown) => void + route( + 'GET /v1/site/archive/languages', + () => + new Promise((resolve) => { + release = resolve + }), + ) + await mountModal() + await choose('Site', 'archive') + await choose('Site', 'atelier') + await choose('Language', 'de') + release(languagesBody('archive')) + await flushPromises() + await flushPromises() + + expect(select('Site')!.props('modelValue')).toBe('atelier') + expect(select('Language')!.props('modelValue')).toBe('de') + }) + + it('clears the reset help on the next change', async () => { + await mountModal() + await choose('Language', 'fr') + await choose('Site', 'notes') + await choose('Site', 'atelier') + + expect(text()).not.toContain('was reset from') + }) + + it('keeps a language the new site offers without a message', async () => { + await mountModal() + await choose('Language', 'fr') + await choose('Site', 'atelier') + + expect(select('Language')!.props('modelValue')).toBe('fr') + expect(text()).not.toContain('was reset from') + expect(text()).not.toContain('Default for Atelier.') + }) + + it('hides Language and the choice and posts the site when the languages of the new site cannot be read', async () => { + route('GET /v1/site/atelier/languages', () => { + throw new ApiError(500) + }) + await mountModal() + await type(TITLE) + await choose('Site', 'atelier') + + expect(select('Language')).toBeUndefined() + await press('Create article') + expect(posts()[0]!.body).toEqual({ title: TITLE, site: 'atelier' }) + }) +}) + +describe('The modal copy and the create request', () => { + it('says Create article and drops the old sentence', async () => { + await mountModal() + + expect(text()).toContain('Give the article a title. You can write the rest in the editor.') + expect(button('Create article')).toBeTruthy() + expect(text()).not.toContain("Titles can't be changed later") + }) + + it('posts the title, language and site for Atelier and French', async () => { + await mountModal() + await choose('Site', 'atelier') + await choose('Language', 'fr') + await type('Couts') + await press('Create article') + + expect(posts()[0]!.body).toEqual({ title: 'Couts', lang: 'fr', site: 'atelier' }) + expect(wrapper.emitted('created')).toHaveLength(1) + }) + + it('always sends the default site key with its language', async () => { + await mountModal() + await type('Plain') + await press('Create article') + + expect(posts()[0]!.body).toEqual({ title: 'Plain', lang: 'en', site: 'journal' }) + }) +}) + +describe('Failures of a new article', () => { + it.each([ + [409, 'language_unavailable'], + [422, 'language_not_enabled'], + ])('refetches the languages and resets on %s %s', async (status, body) => { + await mountModal() + await choose('Language', 'fr') + await type(TITLE) + route('POST /v1/article/', () => { + throw new ApiError(status, undefined, body) + }) + const before = calls.filter((call) => call.path === '/v1/site/journal/languages').length + await press('Create article') + + expect(calls.filter((call) => call.path === '/v1/site/journal/languages')).toHaveLength( + before + 1, + ) + expect(select('Language')!.props('modelValue')).toBe('en') + expect(text()).toContain( + "Couldn't create the article. Your text is still here. Try again in a moment.", + ) + }) + + it('refetches the sites and returns to the default on site_not_found', async () => { + await mountModal() + await choose('Site', 'atelier') + await type(TITLE) + route('POST /v1/article/', () => { + throw new ApiError(404, undefined, 'site_not_found') + }) + await press('Create article') + + expect(calls.filter((call) => call.path === '/v1/site/')).toHaveLength(2) + expect(select('Site')!.props('modelValue')).toBe('journal') + expect(titleInput().value).toBe(TITLE) + expect(text()).toContain("Couldn't create the article.") + }) + + it.each([ + [409, 'setup_required'], + [403, 'missing_permission'], + ])('shows the Error state on %s %s without a global toast', async (status, body) => { + await mountModal() + await type(TITLE) + route('POST /v1/article/', () => { + throw new ApiError(status, undefined, body) + }) + await press('Create article') + + expect(text()).toContain("Couldn't create the article.") + const post = vi + .mocked(useApi) + .mock.results[0]!.value.request.mock.calls.find( + (call: unknown[]) => (call[1] as RequestInit | undefined)?.method === 'POST', + ) + expect((post[1] as { onMissingPermission?: string }).onMissingPermission).toBe( + 'passthrough', + ) + }) +}) + +describe('loc.l3.translation: Translation of an existing article', () => { + const roof = story(10, 5, 'Roof inspection checklist') + + beforeEach(() => { + route('GET /v1/article/', () => ({ + items: [roof], + total: 1, + page: 1, + size: 20, + counts: { all: 1, online: 1, draft: 0, missing: 1 }, + })) + }) + + async function startTranslation() { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + await mountModal() + await type('Spring') + await choose('Language', 'fr') + wrapper + .findComponent({ name: 'LocaleChoiceCards' }) + .vm.$emit('update:modelValue', 'translation') + await flushPromises() + await vi.advanceTimersByTimeAsync(300) + await flushPromises() + } + + it('drops the Site field, changes the copy and queries the missing tab', async () => { + await startTranslation() + + expect(select('Site')).toBeUndefined() + expect(text()).toContain('Create a translation of an article you can read.') + expect(button('Create French draft')).toBeTruthy() + const list = calls.find((call) => call.path === '/v1/article/') + expect(list).toBeDefined() + }) + + it('posts the story, the default-language source and no site', async () => { + await startTranslation() + const picker = wrapper.findComponent(SelectMenu) + picker.vm.$emit('update:modelValue', 10) + await flushPromises() + expect(text()).toContain('No French version yet. Copies from English · published') + await type('Liste de controle') + await press('Create French draft') + + expect(posts()[0]!.body).toEqual({ + title: 'Liste de controle', + lang: 'fr', + story_id: 5, + source_article_id: 10, + copy: true, + }) + expect(wrapper.emitted('created')).toHaveLength(1) + }) + + it('keeps the title and the site when switching back to a new article', async () => { + await startTranslation() + wrapper.findComponent({ name: 'LocaleChoiceCards' }).vm.$emit('update:modelValue', 'new') + await flushPromises() + + expect(titleInput().value).toBe('Spring') + expect(select('Site')!.props('modelValue')).toBe('journal') + }) + + it('shows Open French version when the language exists', async () => { + await startTranslation() + wrapper.findComponent(SelectMenu).vm.$emit('update:modelValue', 10) + await flushPromises() + route('POST /v1/article/', () => { + throw new ApiError(409, undefined, 'language_exists', { existingArticleId: 11 }) + }) + await type('Liste de controle') + await press('Create French draft') + expect(text()).toContain('French already exists for this article') + + await press('Open French version') + expect(wrapper.emitted('open')).toEqual([[11]]) + }) + + it('shows the unreadable-source alert, never a deletion claim, when the source answers 404 Article not found', async () => { + await startTranslation() + wrapper.findComponent(SelectMenu).vm.$emit('update:modelValue', 10) + await flushPromises() + route('POST /v1/article/', () => { + throw new ApiError(404, undefined, 'Article not found') + }) + await type('Liste de controle') + await press('Create French draft') + + expect(text()).toContain("The source isn't available to you") + expect(text()).not.toContain('deleted') + expect(wrapper.emitted('created')).toBeUndefined() + }) +}) diff --git a/web/src/components/localization/LocaleChoiceCards.vue b/web/src/components/localization/LocaleChoiceCards.vue new file mode 100644 index 0000000..f74c0d1 --- /dev/null +++ b/web/src/components/localization/LocaleChoiceCards.vue @@ -0,0 +1,36 @@ +<script setup lang="ts"> +import { computed } from 'vue' + +const props = defineProps<{ + items: { value: string; label: string; description: string }[] + legend: string + disabled?: boolean + compact?: boolean +}>() + +const shown = computed(() => + props.compact ? props.items.map((item) => ({ ...item, description: undefined })) : props.items, +) + +const model = defineModel<string>() +</script> + +<template> + <URadioGroup + v-model="model" + :items="shown" + :legend="legend" + :disabled="disabled" + variant="card" + :ui="{ + root: 'w-full', + legend: 'sr-only', + fieldset: 'flex flex-col gap-2', + item: 'rounded-none border border-accented px-3 py-2.5 has-data-[state=checked]:border-primary has-data-[state=checked]:bg-elevated', + base: 'mt-0.5 size-4 rounded-none', + indicator: 'rounded-none after:size-2 after:rounded-none', + label: 'font-medium text-highlighted', + description: 'text-xs/4.5 text-pretty text-muted', + }" + /> +</template> diff --git a/web/src/components/localization/LocaleDialog.vue b/web/src/components/localization/LocaleDialog.vue index ee7eace..1b73c84 100644 --- a/web/src/components/localization/LocaleDialog.vue +++ b/web/src/components/localization/LocaleDialog.vue @@ -5,12 +5,14 @@ import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClass import LocaleAlert from '@/components/localization/LocaleAlert.vue' import LocaleButton from '@/components/localization/LocaleButton.vue' -withDefaults( +const props = withDefaults( defineProps<{ title: string description?: string - confirmLabel: string + confirmLabel?: string cancelLabel?: string + placement?: string + content?: Record<string, unknown> tone?: 'primary' | 'danger' pending?: boolean confirmDisabled?: boolean @@ -18,7 +20,10 @@ withDefaults( }>(), { description: undefined, + confirmLabel: undefined, cancelLabel: undefined, + placement: 'sm:top-25 sm:max-h-169 sm:max-w-115', + content: undefined, tone: 'primary', pending: false, confirmDisabled: false, @@ -42,10 +47,11 @@ const CLOSE_BUTTON = { :description="description" :dismissible="!pending" :close="pending ? false : CLOSE_BUTTON" + :content="content" :ui="{ - content: `${SHEET_CONTENT_CLASS} sm:top-25 sm:max-w-115`, + content: `${SHEET_CONTENT_CLASS} max-sm:max-h-11/12 ${props.placement}`, close: 'end-6 top-8 md:top-5', - header: 'min-h-0 items-start px-6 pt-5 pb-0 sm:px-6', + header: 'min-h-16 shrink-0 items-start px-6 pt-5 pb-0 sm:min-h-12 sm:px-6', title: 'pe-14 text-lg/6 font-semibold md:pe-10', description: 'mt-1 pe-14 text-sm text-pretty text-toned md:pe-10', body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0', @@ -71,11 +77,13 @@ const CLOSE_BUTTON = { {{ cancelLabel ?? t('Cancel') }} </LocaleButton> <LocaleButton + v-if="confirmLabel" :kind="tone === 'danger' ? 'danger' : 'primary'" :disabled="confirmDisabled || pending" :loading="pending" block class="md:w-auto" + :class="pending ? 'disabled:opacity-100' : ''" @click="emit('confirm')" > {{ confirmLabel }} diff --git a/web/src/components/localization/LocaleSelect.vue b/web/src/components/localization/LocaleSelect.vue index d39f14d..97c098d 100644 --- a/web/src/components/localization/LocaleSelect.vue +++ b/web/src/components/localization/LocaleSelect.vue @@ -5,6 +5,7 @@ defineProps<{ ariaLabel?: string invalid?: boolean readonly?: boolean + shaded?: boolean hasValue?: boolean }>() @@ -28,7 +29,7 @@ const model = defineModel<string | undefined>() base: [ 'h-8 rounded-none border bg-default px-2.5 text-sm ring-0 max-md:h-11', invalid ? 'border-error' : 'border-accented', - readonly ? 'bg-elevated' : '', + shaded ? 'bg-elevated!' : '', ], trailingIcon: 'size-3.5 text-muted', placeholder: 'text-muted', diff --git a/web/src/components/localization/TranslationFailure.vue b/web/src/components/localization/TranslationFailure.vue new file mode 100644 index 0000000..648a3ef --- /dev/null +++ b/web/src/components/localization/TranslationFailure.vue @@ -0,0 +1,71 @@ +<script setup lang="ts"> +import { t } from '@/i18n' + +import { computed } from 'vue' +import LocaleAlert from '@/components/localization/LocaleAlert.vue' +import LocaleButton from '@/components/localization/LocaleButton.vue' +import type { TranslationFailure } from '@/lib/translationFailure' + +type Shown = Exclude<TranslationFailure, 'gone' | 'session'> + +const props = defineProps<{ + kind: Shown + language: string + canOpen?: boolean +}>() + +const emit = defineEmits<{ open: [] }>() + +const copy = computed(() => { + const language = props.language + const copies: Record<Shown, { tone: 'error' | 'warning'; title: string; description: string }> = + { + exists: { + tone: 'error', + title: t('{0} already exists for this article', [language]), + description: t('Open it to continue. Your entries are kept here.'), + }, + hidden: { + tone: 'error', + title: t("{0} can't be created for this article", [language]), + description: t('Try a different language, or ask an owner. Your entries are kept.'), + }, + langOff: { + tone: 'warning', + title: t('{0} was disabled', [language]), + description: t( + '{0} 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.', + [language], + ), + }, + unreadable: { + tone: 'warning', + title: t("The source isn't available to you"), + description: t( + 'You can no longer read the version you chose. Choose another source you can read, or start empty. Your title is kept.', + ), + }, + failed: { + tone: 'error', + title: t("Couldn't create the translation"), + description: t('Your entries are kept. Try again.'), + }, + } + return copies[props.kind] +}) +</script> + +<template> + <LocaleAlert + :tone="copy.tone" + :icon="copy.tone === 'error' ? 'i-lucide-circle-x' : 'i-lucide-triangle-alert'" + :title="copy.title" + :description="copy.description" + > + <template v-if="kind === 'exists' && canOpen" #actions> + <LocaleButton small @click="emit('open')"> + {{ t('Open {0} version', [language]) }} + </LocaleButton> + </template> + </LocaleAlert> +</template> diff --git a/web/src/composables/__tests__/useApi.spec.ts b/web/src/composables/__tests__/useApi.spec.ts index 8fe2de8..689f6dc 100644 --- a/web/src/composables/__tests__/useApi.spec.ts +++ b/web/src/composables/__tests__/useApi.spec.ts @@ -325,3 +325,100 @@ describe('useApi requestPaged', () => { await expect(useApi().request('/v1/site/')).resolves.toEqual([{ id: 1 }]) }) }) + +function textResponse(status: number, body: string, headers: Record<string, string> = {}) { + return { ...mockResponse(status, null, headers), text: async () => body } as unknown as Response +} + +describe('useApi exposes the response headers of a refusal', () => { + it('carries the four localization headers on a 409', async () => { + fetchMock.mockResolvedValue( + textResponse(409, 'language_exists', { + 'X-Existing-Article-Id': '11', + 'X-Current-Revision': '4', + 'X-Missing-Languages': 'fr, de', + 'X-Conflicting-Articles': '12,13', + }), + ) + + const call = useApi().request('/v1/article/12/translations', { method: 'POST' }) + + await expectApiError(call, { + status: 409, + code: 'language_exists', + existingArticleId: 11, + currentRevision: 4, + missingLanguages: ['fr', 'de'], + conflictingArticles: [12, 13], + }) + }) + + it('leaves the headers undefined when the response has none', async () => { + fetchMock.mockResolvedValue(textResponse(409, 'revision_conflict')) + + const call = useApi().request('/v1/article/12', { method: 'PATCH' }) + + await expectApiError(call, { + code: 'revision_conflict', + existingArticleId: undefined, + currentRevision: undefined, + missingLanguages: undefined, + conflictingArticles: undefined, + }) + }) + + it('ignores malformed numeric and blank list headers', async () => { + fetchMock.mockResolvedValue( + textResponse(409, 'revision_conflict', { + 'X-Existing-Article-Id': 'abc', + 'X-Current-Revision': '-4', + 'X-Missing-Languages': ' ', + 'X-Conflicting-Articles': 'abc, 0, -3', + }), + ) + + const call = useApi().request('/v1/article/12', { method: 'PATCH' }) + + await expectApiError(call, { + existingArticleId: undefined, + currentRevision: undefined, + missingLanguages: undefined, + conflictingArticles: undefined, + }) + }) + + it('keeps only positive integer ids of the conflicting articles header', async () => { + fetchMock.mockResolvedValue( + textResponse(409, 'slug_conflict', { 'X-Conflicting-Articles': '12, abc, 1.5, 0, 13' }), + ) + + const call = useApi().request('/v1/story/5/move', { method: 'POST' }) + + await expectApiError(call, { conflictingArticles: [12, 13] }) + }) + + it('carries the headers on a 403 as well', async () => { + fetchMock.mockResolvedValue( + textResponse(403, 'missing_permission', { 'X-Current-Revision': '7' }), + ) + + const call = useApi().request('/v1/story/5/move', { + method: 'POST', + onMissingPermission: 'passthrough', + }) + + await expectApiError(call, { status: 403, currentRevision: 7 }) + }) + + it('keeps the missing-permission toast by default and honours passthrough', async () => { + fetchMock.mockResolvedValue(textResponse(403, 'missing_permission')) + + const call = useApi().request('/v1/story/5/move', { + method: 'POST', + onMissingPermission: 'passthrough', + }) + + await expectApiError(call, { status: 403, code: 'missing_permission' }) + expect(document.body.textContent).not.toContain('permission') + }) +}) diff --git a/web/src/composables/__tests__/useArticleDraft.spec.ts b/web/src/composables/__tests__/useArticleDraft.spec.ts index 424272f..918858a 100644 --- a/web/src/composables/__tests__/useArticleDraft.spec.ts +++ b/web/src/composables/__tests__/useArticleDraft.spec.ts @@ -126,3 +126,111 @@ describe('A 401 response mirrors the body to sessionStorage', () => { ) }) }) + +function sentBody(fetchMock: ReturnType<typeof vi.fn>) { + return JSON.parse((fetchMock.mock.calls[0]![1] as RequestInit).body as string) +} + +function loadedAt(revision: number | undefined, title = 'Couts') { + setActivePinia(createPinia()) + const draft = useArticleDraft(12, USER_ID) + draft.load('<p>Original</p>', new Date(), undefined, undefined, title, revision) + return draft +} + +describe('Saving sends the revision the editor loaded', () => { + it('adds if_revision to the changed fields and takes the revision of the answer', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(mockResponse(200, { id: 12, body: '<p>New</p>', revision: 4 })) + global.fetch = fetchMock as unknown as typeof fetch + const draft = loadedAt(3) + draft.edit('<p>New</p>') + + await draft.save() + + expect(sentBody(fetchMock)).toEqual({ body: '<p>New</p>', if_revision: 3 }) + expect(draft.baseRevision.value).toBe(4) + }) + + it('can save over a newer revision the dialog showed', async () => { + const fetchMock = vi.fn().mockResolvedValue(mockResponse(200, { id: 12, revision: 5 })) + global.fetch = fetchMock as unknown as typeof fetch + const draft = loadedAt(3) + draft.edit('<p>New</p>') + + await draft.save({ revision: 4 }) + + expect(sentBody(fetchMock).if_revision).toBe(4) + expect(draft.baseRevision.value).toBe(5) + }) + + it('sends nothing when nothing changed', async () => { + const fetchMock = vi.fn() + global.fetch = fetchMock as unknown as typeof fetch + const draft = loadedAt(3) + + expect(await draft.save()).toBe('saved') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('answers conflict on a revision_conflict and keeps the edits dirty', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ...mockResponse(409), + headers: { get: (name: string) => (name === 'X-Current-Revision' ? '4' : null) }, + text: async () => 'revision_conflict', + }) as unknown as typeof fetch + const draft = loadedAt(3) + draft.edit('<p>New</p>') + + expect(await draft.save()).toBe('conflict') + expect(draft.state.value).toBe('dirty') + expect(draft.body.value).toBe('<p>New</p>') + }) +}) + +describe('A mirrored draft keeps the revision it was written against', () => { + it('mirrors the base revision and restores it on load', () => { + const first = loadedAt(3) + first.edit('<p>Offline</p>') + first.mirrorToStorage() + + expect(readStoredDraft(USER_ID, 12)?.revision).toBe(3) + const second = useArticleDraft(12, USER_ID) + second.load('<p>Server</p>', new Date(), undefined, undefined, 'Couts', 5) + + expect(second.baseRevision.value).toBe(3) + }) + + it('uses the revision loaded now for a stored draft that has none', () => { + sessionStorage.setItem( + 'renewcms:article-draft:7:12', + JSON.stringify({ body: '<p>Old</p>', mirrored_at: '2026-09-26T14:18:00Z' }), + ) + const draft = loadedAt(5) + + expect(draft.baseRevision.value).toBe(5) + }) +}) + +describe('Reloading theirs replaces the edits', () => { + it('is clean, forgets the stored draft and takes the fetched revision', () => { + const draft = loadedAt(3) + draft.edit('<p>Mine</p>') + draft.mirrorToStorage() + + draft.replaceWith({ + id: 12, + title: 'Theirs', + body: '<p>Theirs</p>', + revision: 4, + updated_at: '2026-10-14T14:32:00Z', + } as never) + + expect(draft.body.value).toBe('<p>Theirs</p>') + expect(draft.title.value).toBe('Theirs') + expect(draft.state.value).toBe('clean') + expect(readStoredDraft(USER_ID, 12)).toBeNull() + expect(draft.baseRevision.value).toBe(4) + }) +}) diff --git a/web/src/composables/__tests__/useEditorMove.spec.ts b/web/src/composables/__tests__/useEditorMove.spec.ts new file mode 100644 index 0000000..942f652 --- /dev/null +++ b/web/src/composables/__tests__/useEditorMove.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import type { Article } from '@/composables/useArticles' +import type { Site } from '@/composables/useSites' +import { useEditorMove } from '@/composables/useEditorMove' + +const opened: string[] = [] +const choice = { key: 'journal' } + +vi.mock('@/composables/useApi', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/composables/useApi')>() + return { ...actual, useApi: () => ({ request: vi.fn() }) } +}) + +vi.mock('@/i18n/presentation', () => ({ + useLocalizedToast: () => ({ add: vi.fn() }), + useLocalizedOverlay: () => ({ + create: (component: { __name?: string }) => ({ + open: async () => { + opened.push(component.__name ?? '') + return choice.key + }, + }), + }), +})) + +function site(id: number, key: string): Site { + return { id, key, name: key } as Site +} + +describe('Move article refuses the current site', () => { + it('never opens the confirmation when the panel answers the site the article is on', async () => { + opened.length = 0 + const movement = useEditorMove({ + article: ref({ id: 12, site_id: 1 } as Article), + sites: ref([site(1, 'journal'), site(2, 'atelier')]), + readable: () => [], + multilingual: () => false, + onMoved: vi.fn(), + onGone: vi.fn(), + refreshSites: vi.fn(), + }) + + await movement.open() + + expect(opened).toHaveLength(1) + }) +}) diff --git a/web/src/composables/useApi.ts b/web/src/composables/useApi.ts index 3df5273..2398723 100644 --- a/web/src/composables/useApi.ts +++ b/web/src/composables/useApi.ts @@ -2,7 +2,7 @@ import { ref } from 'vue' import { accountDeletedRedirect } from '@/lib/accountDeletion' import router from '@/router' import { useSessionStore } from '@/stores/session' -import { ApiError, errorCodeOf, knownErrorCode } from '@/lib/apiError' +import { ApiError, errorCodeOf, knownErrorCode, type ApiErrorDetails } from '@/lib/apiError' import { filenameFromDisposition } from '@/lib/contentDisposition' import { missingPermissionCopy } from '@/lib/permissionCopy' import { useLocalizedToast } from '@/i18n/presentation' @@ -148,6 +148,35 @@ function headerOrUndefined(response: Response, name: string) { return response.headers.get(name) ?? undefined } +function numberHeader(response: Response, name: string) { + const value = response.headers.get(name) + return value !== null && /^\d+$/.test(value) ? Number.parseInt(value, 10) : undefined +} + +function listHeader(response: Response, name: string) { + const value = response.headers.get(name) + return value === null || value.trim() === '' + ? undefined + : value.split(',').map((part) => part.trim()) +} + +function positiveIdsHeader(response: Response, name: string) { + const ids = (listHeader(response, name) ?? []) + .filter((part) => /^\d+$/.test(part)) + .map((part) => Number.parseInt(part, 10)) + .filter((id) => id > 0) + return ids.length > 0 ? ids : undefined +} + +function responseDetails(response: Response): ApiErrorDetails { + return { + existingArticleId: numberHeader(response, 'X-Existing-Article-Id'), + currentRevision: numberHeader(response, 'X-Current-Revision'), + missingLanguages: listHeader(response, 'X-Missing-Languages'), + conflictingArticles: positiveIdsHeader(response, 'X-Conflicting-Articles'), + } +} + async function failForbidden( response: Response, onForbidden: OnForbidden, @@ -164,7 +193,12 @@ async function failForbidden( showMissingPermissionToast(permission) } } - throw new ApiError(403, undefined, body, { code, permission, escalationKey }) + throw new ApiError(403, undefined, body, { + ...responseDetails(response), + code, + permission, + escalationKey, + }) } export function useApi() { @@ -207,6 +241,7 @@ export function useApi() { throw new ApiError(response.status, undefined, body, { code: errorCodeOf(body) ?? knownErrorCode(response.headers.get('X-Error-Code')), lastRole: headerOrUndefined(response, 'X-Last-Role'), + ...responseDetails(response), }) } diff --git a/web/src/composables/useArticleDraft.ts b/web/src/composables/useArticleDraft.ts index 8bc0c3e..9d4fc7a 100644 --- a/web/src/composables/useArticleDraft.ts +++ b/web/src/composables/useArticleDraft.ts @@ -3,7 +3,7 @@ import { ApiError, useApi } from '@/composables/useApi' import type { Article, Byline } from '@/composables/useArticles' import { sameIds, toBylines } from '@/lib/articleMeta' import { EMPTY_SEO, seoChanges, seoOf, type ArticleSeo } from '@/lib/articleSeo' -import { AUTHOR_CODES, SITE_CODES, codeOf, statusOf } from '@/lib/directoryErrors' +import { AUTHOR_CODES, codeOf, statusOf } from '@/lib/directoryErrors' import { TITLE_SERVER_MESSAGE } from '@/lib/editorTitle' import { readStoredDraft, @@ -19,11 +19,11 @@ export type SaveOutcome = | 'error' | 'not-found' | 'unauthorized' - | 'site-gone' | 'author-gone' | 'slug-taken' | 'invalid-url' | 'invalid-title' + | 'conflict' export interface ArticleMeta { siteId: number | null @@ -32,6 +32,11 @@ export interface ArticleMeta { const SLUG_TAKEN_CODE = 'slug_taken' const INVALID_URL_CODE = 'invalid_url' +const REVISION_CONFLICT_CODE = 'revision_conflict' + +export interface SaveOptions { + revision?: number +} export function useArticleDraft(articleId: number, userId: number | null = null) { const { request } = useApi() @@ -47,6 +52,8 @@ export function useArticleDraft(articleId: number, userId: number | null = null) const savedMeta = ref<ArticleMeta>({ siteId: null, authors: [] }) const seo = ref<ArticleSeo>({ ...EMPTY_SEO }) const savedSeo = ref<ArticleSeo>({ ...EMPTY_SEO }) + const baseRevision = ref<number | undefined>(undefined) + const lastFailureStatus = ref(0) const businessState = ref<ArticleDraftBusinessState>('clean') const savedAt = ref<Date | null>(null) const offline = ref(typeof navigator === 'undefined' ? false : !navigator.onLine) @@ -73,10 +80,6 @@ export function useArticleDraft(articleId: number, userId: number | null = null) offline.value ? 'offline' : businessState.value, ) - function siteChanged() { - return meta.value.siteId !== savedMeta.value.siteId - } - function authorsChanged() { return !sameIds(meta.value.authors, savedMeta.value.authors) } @@ -91,13 +94,16 @@ export function useArticleDraft(articleId: number, userId: number | null = null) function mirrorToStorage() { const draft: StoredDraft = { mirrored_at: new Date().toISOString() } + if (baseRevision.value !== undefined) { + draft.revision = baseRevision.value + } if (titleChanged()) { draft.title = title.value } if (bodyEdited.value) { draft.body = body.value } - if (siteChanged() || authorsChanged()) { + if (authorsChanged()) { draft.meta = { site_id: meta.value.siteId, authors: meta.value.authors } } if (Object.keys(seoPatch()).length > 0) { @@ -116,6 +122,7 @@ export function useArticleDraft(articleId: number, userId: number | null = null) initialMeta?: ArticleMeta, initialSeo?: ArticleSeo, initialTitle = '', + initialRevision?: number, ) { const stored = readStoredDraft(userId, articleId) savedAt.value = initialSavedAt @@ -123,8 +130,9 @@ export function useArticleDraft(articleId: number, userId: number | null = null) title.value = stored?.title ?? initialTitle savedMeta.value = initialMeta ?? { siteId: null, authors: [] } meta.value = stored?.meta - ? { siteId: stored.meta.site_id, authors: toBylines(stored.meta.authors) } + ? { siteId: savedMeta.value.siteId, authors: toBylines(stored.meta.authors) } : savedMeta.value + baseRevision.value = stored?.revision ?? initialRevision savedSeo.value = initialSeo ?? { ...EMPTY_SEO } seo.value = stored?.seo ? seoOf(stored.seo, savedSeo.value) : savedSeo.value bodyEdited.value = stored?.body !== undefined @@ -165,12 +173,12 @@ export function useArticleDraft(articleId: number, userId: number | null = null) } } - function savePayload() { + function savePayload(revision: number | undefined) { return { + ...(revision !== undefined ? { if_revision: revision } : {}), ...seoPatch(), ...(titleChanged() ? { title: title.value } : {}), ...(bodyEdited.value ? { body: body.value } : {}), - ...(siteChanged() ? { site_id: meta.value.siteId } : {}), ...(authorsChanged() ? { author_ids: meta.value.authors.map((author) => author.id) } : {}), @@ -193,26 +201,33 @@ export function useArticleDraft(articleId: number, userId: number | null = null) if (statusOf(err) !== 404) { return null } - const code = codeOf(err) - if (code === SITE_CODES.notFound) { - return 'site-gone' - } - return code === AUTHOR_CODES.notFound ? 'author-gone' : null + return codeOf(err) === AUTHOR_CODES.notFound ? 'author-gone' : null } - async function save(): Promise<SaveOutcome> { + function conflictOutcome(err: unknown): SaveOutcome | null { + if (statusOf(err) !== 409 || codeOf(err) !== REVISION_CONFLICT_CODE) { + return null + } + return 'conflict' + } + + async function save(options: SaveOptions = {}): Promise<SaveOutcome> { businessState.value = 'saving' savingTitle.value = titleChanged() - const payload = savePayload() + const revision = options.revision ?? baseRevision.value + lastFailureStatus.value = 0 + const changes = savePayload(undefined) + const payload = savePayload(revision) try { const saved = - Object.keys(payload).length > 0 + Object.keys(changes).length > 0 ? await request<Article>(`/v1/article/${articleId}?format=html`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) : undefined + baseRevision.value = saved?.revision ?? baseRevision.value businessState.value = 'saved' savingTitle.value = false savedAt.value = new Date() @@ -226,6 +241,12 @@ export function useArticleDraft(articleId: number, userId: number | null = null) return 'saved' } catch (err) { savingTitle.value = false + lastFailureStatus.value = statusOf(err) + const conflict = conflictOutcome(err) + if (conflict) { + businessState.value = 'dirty' + return conflict + } const rejected = rejectedOutcome(err) if (rejected) { businessState.value = 'dirty' @@ -250,7 +271,46 @@ export function useArticleDraft(articleId: number, userId: number | null = null) } } + function setBaseRevision(revision: number | undefined) { + baseRevision.value = revision + } + + function syncSite(siteId: number | null) { + savedMeta.value = { ...savedMeta.value, siteId } + meta.value = { ...meta.value, siteId } + } + + function markFailed() { + businessState.value = 'error' + } + + function replaceWith(theirs: Article) { + title.value = theirs.title + savedTitle.value = theirs.title + titleServerError.value = '' + body.value = theirs.body + bodyEdited.value = false + savedMeta.value = { + siteId: theirs.site_id ?? null, + authors: toBylines(theirs.authors ?? []), + } + meta.value = savedMeta.value + savedSeo.value = seoOf(theirs) + seo.value = savedSeo.value + baseRevision.value = theirs.revision + savedAt.value = new Date(theirs.updated_at) + restoredAt.value = null + businessState.value = 'clean' + discardStored() + } + return { + baseRevision, + lastFailureStatus, + setBaseRevision, + syncSite, + markFailed, + replaceWith, title, titleServerError, savingTitle, diff --git a/web/src/composables/useEditorConflict.ts b/web/src/composables/useEditorConflict.ts new file mode 100644 index 0000000..6517ae8 --- /dev/null +++ b/web/src/composables/useEditorConflict.ts @@ -0,0 +1,97 @@ +import type { Ref } from 'vue' +import { t } from '@/i18n' +import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation' +import EditorConflictDialog, { type ConflictResult } from '@/components/EditorConflictDialog.vue' +import { useApi } from '@/composables/useApi' +import type { Article } from '@/composables/useArticles' +import type { SaveOutcome, useArticleDraft } from '@/composables/useArticleDraft' +import { announce } from '@/composables/useLiveAnnouncement' +import { conflictDownloadText } from '@/lib/bodyPlainText' +import { statusOf } from '@/lib/directoryErrors' +import { downloadBlob } from '@/lib/downloadBlob' + +interface ConflictOptions { + articleId: number + draft: ReturnType<typeof useArticleDraft> + article: Ref<Article | null> + language: () => string | null + saveOver: (revision: number | undefined) => Promise<SaveOutcome> + onReloaded: (theirs: Article) => void + onGone: () => void + onClosed: (result: ConflictResult) => void +} + +const DOWNLOAD_MIME = 'text/plain;charset=utf-8' +const DOWNLOAD_EXTENSION = '.txt' + +export function useEditorConflict(options: ConflictOptions) { + const { request } = useApi() + const overlay = useLocalizedOverlay() + const toast = useLocalizedToast() + + async function fetchTheirs() { + try { + return await request<Article>(`/v1/article/${options.articleId}?format=html`) + } catch (error) { + if (statusOf(error) === 404) { + options.onGone() + } + return null + } + } + + function download() { + const text = conflictDownloadText(options.draft.title.value, options.draft.body.value) + downloadBlob( + new Blob([text], { type: DOWNLOAD_MIME }), + `${options.draft.title.value || 'article'}${DOWNLOAD_EXTENSION}`, + ) + toast.add(() => ({ + color: 'success', + icon: 'i-lucide-circle-check', + get title() { + return t('Download started') + }, + get description() { + const language = options.language() + return language + ? t( + 'Your {0} title and body are saved as a text file. Your changes are still in the editor.', + [language], + ) + : t( + 'Your title and body are saved as a text file. Your changes are still in the editor.', + ) + }, + })) + announce(t('Download started')) + } + + async function saveOver(revision: number | undefined) { + const outcome = await options.saveOver(revision) + return { outcome, status: options.draft.lastFailureStatus.value } + } + + async function begin() { + const theirs = await fetchTheirs() + if (!theirs) { + return false + } + overlay + .create(EditorConflictDialog, { + props: { + theirs, + restoredAt: options.draft.restoredAt.value, + fetchTheirs, + attemptSave: saveOver, + download, + applyTheirs: options.onReloaded, + }, + }) + .open() + .then((result) => options.onClosed(result as ConflictResult)) + return true + } + + return { begin } +} diff --git a/web/src/composables/useEditorDeletion.ts b/web/src/composables/useEditorDeletion.ts new file mode 100644 index 0000000..1cdbf83 --- /dev/null +++ b/web/src/composables/useEditorDeletion.ts @@ -0,0 +1,222 @@ +import type { Ref } from 'vue' +import { useRouter } from 'vue-router' +import { t } from '@/i18n' +import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation' +import EditorTaskDialog, { type TaskResult } from '@/components/EditorTaskDialog.vue' +import { useApi } from '@/composables/useApi' +import type { Article, CoverageEntry } from '@/composables/useArticles' +import { statusOf } from '@/lib/directoryErrors' +import { joinNames } from '@/lib/nameList' +import { nameInSentence } from '@/lib/storyList' + +interface DeletionOptions { + article: Ref<Article | null> + readable: () => CoverageEntry[] + language: () => string | null + classicDelete: () => Promise<unknown> + refetch: () => Promise<void> + onDeleted: () => void +} + +type Outcome = 'deleted' | 'gone' | 'blocked' | 'partial' + +const FORBIDDEN = 403 +const NOT_FOUND = 404 + +export function useEditorDeletion(options: DeletionOptions) { + const { request } = useApi() + const router = useRouter() + const overlay = useLocalizedOverlay() + const toast = useLocalizedToast() + + function remove(id: number) { + return request(`/v1/article/${id}`, { + method: 'DELETE', + onMissingPermission: 'passthrough', + }) + } + + function ask(copy: { + title: string + description: string + confirmLabel: string + cancelLabel?: string + run: () => Promise<TaskResult> + }) { + return overlay + .create(EditorTaskDialog, { props: { ...copy, tone: 'danger' } }) + .open() as Promise<unknown> + } + + function toastDone(title: string, description?: string) { + toast.add(() => ({ + color: 'success', + icon: 'i-lucide-circle-check', + get title() { + return title + }, + get description() { + return description + }, + })) + } + + function toastPartial() { + toast.add(() => ({ + color: 'error', + icon: 'i-lucide-circle-x', + get title() { + return t("Couldn't delete the whole article") + }, + get description() { + return t('Some versions were deleted. Open the language menu to see what is left.') + }, + })) + } + + function failure(): TaskResult { + return { + done: false, + error: { + title: t( + "Couldn't delete the article, so it's still there. Try again in a moment.", + ), + }, + } + } + + function finish(outcome: Outcome | undefined, doneTitle: string, doneText?: string) { + if (outcome === 'deleted') { + options.onDeleted() + router.push('/article') + toastDone(doneTitle, doneText) + } else if (outcome === 'gone') { + options.onDeleted() + router.push('/article') + } + } + + async function removeOpen(): Promise<TaskResult> { + const current = options.article.value + if (!current) { + return failure() + } + try { + await remove(current.id) + return { done: true, value: 'deleted' } + } catch (error) { + return statusOf(error) === NOT_FOUND ? { done: true, value: 'gone' } : failure() + } + } + + async function removeAll(): Promise<TaskResult> { + const current = options.article.value + if (!current) { + return failure() + } + const ids = [ + ...options + .readable() + .filter((entry) => entry.id !== current.id) + .map((entry) => entry.id), + current.id, + ] + for (const [index, id] of ids.entries()) { + try { + await remove(id) + } catch (error) { + if (statusOf(error) === NOT_FOUND) { + continue + } + const blocked = index === 0 && statusOf(error) === FORBIDDEN + return { done: true, value: blocked ? 'blocked' : 'partial' } + } + } + return { done: true, value: 'deleted' } + } + + async function deleteVersion() { + const language = options.language() + const current = options.article.value + if (!language || !current) { + await options.classicDelete() + return + } + const outcome = (await ask({ + title: t('Delete the {0} version?', [language]), + description: t('"{0}" will be deleted. Other versions stay.', [current.title]), + confirmLabel: t('Delete {0} version', [language]), + run: removeOpen, + })) as Outcome | undefined + finish( + outcome, + t('{0} version deleted', [language]), + t('"{0}" is gone. Other versions stay.', [current.title]), + ) + } + + async function deleteBlocked() { + const language = options.language() ?? '' + const choice = await ask({ + title: t("You can't delete this article"), + description: t( + 'Deleting an article needs delete permission on every version. You can still delete the version you are editing.', + ), + confirmLabel: t('Delete {0} version', [language]), + cancelLabel: t('Close'), + run: async () => ({ done: true, value: 'switch' }), + }) + if (choice === 'switch') { + await deleteVersion() + } + } + + async function deleteOnlyVersion(language: string) { + const outcome = (await ask({ + title: t('Delete the only version?'), + description: t( + '{0} is the only version of this article. Deleting it deletes the article.', + [language], + ), + confirmLabel: t('Delete article'), + run: removeOpen, + })) as Outcome | undefined + finish(outcome, t('Article deleted')) + } + + async function deleteWholeArticle() { + const names = options.readable().map((entry) => nameInSentence(entry.lang)) + const outcome = (await ask({ + title: t('Delete {0}?', [joinNames(names)]), + description: t( + "This deletes the article and every version you can read. It can't be undone.", + ), + confirmLabel: t('Delete article'), + run: removeAll, + })) as Outcome | undefined + if (outcome === 'blocked') { + await deleteBlocked() + } else if (outcome === 'partial') { + toastPartial() + await options.refetch().catch(() => undefined) + } else { + finish(outcome, t('Article deleted')) + } + } + + async function deleteArticle() { + const language = options.language() + const current = options.article.value + if (!language || !current) { + await options.classicDelete() + return + } + if (options.readable().length <= 1) { + await deleteOnlyVersion(language) + return + } + await deleteWholeArticle() + } + + return { deleteVersion, deleteArticle } +} diff --git a/web/src/composables/useEditorMove.ts b/web/src/composables/useEditorMove.ts new file mode 100644 index 0000000..2d7fcb4 --- /dev/null +++ b/web/src/composables/useEditorMove.ts @@ -0,0 +1,173 @@ +import type { Ref } from 'vue' +import { t } from '@/i18n' +import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation' +import MoveArticleDialog from '@/components/MoveArticleDialog.vue' +import MoveArticlePanel from '@/components/MoveArticlePanel.vue' +import { ApiError, useApi } from '@/composables/useApi' +import type { Article, CoverageEntry } from '@/composables/useArticles' +import { announce } from '@/composables/useLiveAnnouncement' +import { useSiteLanguages } from '@/composables/useSiteLanguages' +import type { Site } from '@/composables/useSites' +import { codeOf, statusOf } from '@/lib/directoryErrors' +import { moveVersions, type MoveResult } from '@/lib/moveArticle' +import { nameInSentence } from '@/lib/storyList' + +interface MoveOptions { + article: Ref<Article | null> + sites: Ref<Site[]> + readable: () => CoverageEntry[] + multilingual: () => boolean + onMoved: () => Promise<void> + onGone: () => void + refreshSites: () => Promise<void> +} + +const FORBIDDEN = 403 +const NOT_FOUND = 404 +const UNPROCESSABLE = 422 +const CONFLICT = 409 + +export function useEditorMove(options: MoveOptions) { + const { request } = useApi() + const { getLanguages } = useSiteLanguages() + const overlay = useLocalizedOverlay() + const toast = useLocalizedToast() + + function readableLangs() { + return options.readable().map((entry) => entry.lang) + } + + async function enabledNames(key: string) { + try { + const languages = await getLanguages(key) + return languages.languages + .filter((language) => language.enabled) + .map((language) => nameInSentence(language.lang)) + } catch { + return null + } + } + + async function languagesRefusal(key: string, error: ApiError): Promise<MoveResult> { + const known = readableLangs() + const missing = (error.missingLanguages ?? []).filter((lang) => known.includes(lang)) + return { + kind: 'languages', + missing: missing.map((lang) => nameInSentence(lang)), + enabled: await enabledNames(key), + } + } + + async function slugRefusal(error: ApiError): Promise<MoveResult> { + const readable = options.readable() + const ids = (error.conflictingArticles ?? []).filter((id) => + readable.some((entry) => entry.id === id), + ) + const conflicts = await Promise.all( + ids.map(async (id) => { + try { + const found = await request<Article>(`/v1/article/${id}`) + return { + language: nameInSentence(found.lang ?? ''), + slug: found.slug ?? '', + } + } catch { + return null + } + }), + ) + return { kind: 'slug', conflicts: conflicts.filter((entry) => entry !== null) } + } + + async function refusalOf(error: unknown, key: string): Promise<MoveResult> { + const status = statusOf(error) + const code = codeOf(error) + if (error instanceof ApiError) { + if (status === UNPROCESSABLE && code === 'language_not_enabled') { + return languagesRefusal(key, error) + } + if (status === CONFLICT && code === 'slug_conflict') { + return slugRefusal(error) + } + } + if (status === FORBIDDEN) { + return { kind: 'perm' } + } + if (status === NOT_FOUND && code === 'site_not_found') { + await options.refreshSites() + return { kind: 'failed' } + } + return status === NOT_FOUND ? { kind: 'gone' } : { kind: 'failed' } + } + + async function send(key: string): Promise<MoveResult> { + const storyId = options.article.value?.story_id + try { + await request(`/v1/story/${storyId}/move`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ site: key }), + onMissingPermission: 'passthrough', + }) + return { kind: 'moved' } + } catch (error) { + return refusalOf(error, key) + } + } + + function announceMoved(destination: string, count: number) { + toast.add(() => ({ + color: 'success', + icon: 'i-lucide-circle-check', + get title() { + return t('Moved to {0}', [destination]) + }, + get description() { + return count > 1 + ? t('All {0} versions moved together.', [count]) + : t('The article moved.') + }, + })) + announce(t('Moved to {0}', [destination])) + } + + async function open() { + const current = options.article.value + if (!current) { + return + } + const versions = options.multilingual() ? moveVersions(options.readable()) : [] + const key = (await overlay + .create(MoveArticlePanel, { + props: { + sites: options.sites.value, + currentSiteId: current.site_id ?? null, + versions, + }, + }) + .open()) as string | undefined + const target = options.sites.value.find((site) => site.key === key) + const origin = options.sites.value.find((site) => site.id === current.site_id) + if (!target || target.id === current.site_id) { + return + } + const outcome = await overlay + .create(MoveArticleDialog, { + props: { + destination: target.name, + origin: origin?.name ?? '', + versions, + run: () => send(target.key), + }, + }) + .open() + if (outcome === true) { + announceMoved(target.name, Math.max(versions.length, 1)) + await options.onMoved() + } else if (outcome === 'gone') { + options.onGone() + } + } + + return { open } +} diff --git a/web/src/composables/useEditorSeoPanel.ts b/web/src/composables/useEditorSeoPanel.ts index e0cdaf5..8322e00 100644 --- a/web/src/composables/useEditorSeoPanel.ts +++ b/web/src/composables/useEditorSeoPanel.ts @@ -7,7 +7,7 @@ import { SEO_LIMITS, charCount, seoChanges, type ArticleSeo } from '@/lib/articl import type { DirectoryNotice } from '@/lib/directoryTypes' import type { SeoCheckBody, SeoFocus, SuggestionField } from '@/lib/seoTypes' -const CLOSING_OUTCOMES: SaveOutcome[] = ['not-found', 'site-gone', 'author-gone', 'unauthorized'] +const CLOSING_OUTCOMES: SaveOutcome[] = ['not-found', 'author-gone', 'unauthorized'] interface EditorSeoPanelOptions { articleId: number diff --git a/web/src/composables/useEditorTranslation.ts b/web/src/composables/useEditorTranslation.ts new file mode 100644 index 0000000..1928c7c --- /dev/null +++ b/web/src/composables/useEditorTranslation.ts @@ -0,0 +1,149 @@ +import { watch, type Ref } from 'vue' +import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router' +import { locale, t } from '@/i18n' +import { useLocalizedOverlay } from '@/i18n/presentation' +import AddTranslationDialog, { + type AddTranslationResult, +} from '@/components/AddTranslationDialog.vue' +import type { Article, CoverageEntry } from '@/composables/useArticles' +import { ADD_TRANSLATION_DIALOG as DIALOG_NAME } from '@/lib/addTranslationLocation' +import { holds } from '@/lib/editorCapabilities' +import type { VersionRow } from '@/lib/editorVersions' +import { languageName, type SiteLanguage } from '@/lib/siteLanguages' + +interface TranslationOptions { + article: Ref<Article | null> + loaded: Ref<boolean> + languages: () => SiteLanguage[] + refetchLanguages: () => Promise<SiteLanguage[]> + switchTo: (row: VersionRow, announcement?: string) => Promise<void> + onGone: () => void +} + +function versionRowFor(id: number, lang: string): VersionRow { + return { + key: `version-${id}`, + lang, + name: languageName(lang, locale.value), + code: lang.toUpperCase(), + articleId: id, + title: '', + current: false, + online: false, + readOnly: false, + languageDisabled: false, + addTranslation: false, + } +} + +function entryOf(created: Article, lang: string): CoverageEntry { + return { + id: created.id, + lang, + title: created.title, + is_online: created.is_online, + language_enabled: true, + capabilities: created.capabilities, + } +} + +export function useEditorTranslation(options: TranslationOptions) { + const route = useRoute() + const router = useRouter() + const overlay = useLocalizedOverlay() + let instance: ReturnType<typeof overlay.create> | null = null + + function strippedQuery(): LocationQueryRaw { + const rest = { ...route.query } + delete rest.dialog + delete rest.lang + return rest + } + + function stripParameters() { + if (route.query.dialog !== undefined || route.query.lang !== undefined) { + return router.replace({ query: strippedQuery() }) + } + return undefined + } + + function open(lang?: string) { + return router.push({ + query: { ...route.query, dialog: DIALOG_NAME, ...(lang ? { lang } : {}) }, + }) + } + + async function settle(result: AddTranslationResult | undefined) { + if (!result) { + await stripParameters() + return + } + if (result.kind === 'gone') { + await stripParameters() + options.onGone() + return + } + await stripParameters() + if (result.kind === 'open') { + await options.switchTo(versionRowFor(result.id, result.lang)) + return + } + const current = options.article.value + if (current) { + options.article.value = { + ...current, + translations: [ + ...(current.translations ?? []), + entryOf(result.article, result.lang), + ], + } + } + const name = languageName(result.lang, locale.value) + await options.switchTo( + versionRowFor(result.article.id, result.lang), + t('{0} draft created', [name]), + ) + } + + async function show(lang: string | undefined) { + const created = overlay.create(AddTranslationDialog, { + props: { + get article() { + return options.article.value! + }, + get languages() { + return options.languages() + }, + initialLang: lang, + refetchLanguages: options.refetchLanguages, + }, + }) + instance = created + const result = (await created.open()) as AddTranslationResult | undefined + instance = null + await settle(result) + } + + watch( + () => [route.query.dialog, route.query.lang, options.loaded.value] as const, + ([dialog, lang, loaded]) => { + if (!loaded) { + return + } + if (dialog !== DIALOG_NAME) { + instance?.close() + return + } + if (!holds(options.article.value?.capabilities, 'create_translation')) { + stripParameters() + return + } + if (!instance) { + show(typeof lang === 'string' ? lang : undefined) + } + }, + { immediate: true }, + ) + + return { open } +} diff --git a/web/src/composables/useEditorVersions.ts b/web/src/composables/useEditorVersions.ts index 3ae24c4..6662da2 100644 --- a/web/src/composables/useEditorVersions.ts +++ b/web/src/composables/useEditorVersions.ts @@ -46,6 +46,14 @@ export function useEditorVersions( }) : [], ) + const orderedReadable = computed(() => { + const order = siteLanguages.value.map((language) => language.lang) + const rank = (tag: string) => { + const index = order.indexOf(tag) + return index === -1 ? order.length : index + } + return [...translations.value].sort((left, right) => rank(left.lang) - rank(right.lang)) + }) const siblingNames = computed(() => siblings.value.map((entry) => nameInSentence(entry.lang))) const onlineSiblingNames = computed(() => siblings.value @@ -66,6 +74,16 @@ export function useEditorVersions( } } + async function refreshLanguages() { + const current = article.value + const key = sites.value.find((site) => site.id === current?.site_id)?.key + if (!key) { + return siteLanguages.value + } + siteLanguages.value = (await getLanguages(key)).languages + return siteLanguages.value + } + function markOnline(id: number, online: boolean) { const current = article.value if (!current?.translations) { @@ -93,10 +111,13 @@ export function useEditorVersions( multilingual, languageDisabled, siblings, + orderedReadable, rows, siblingNames, onlineSiblingNames, + siteLanguages, loadLanguages, + refreshLanguages, markOnline, markTitle, } diff --git a/web/src/composables/useNewArticleSites.ts b/web/src/composables/useNewArticleSites.ts new file mode 100644 index 0000000..631381f --- /dev/null +++ b/web/src/composables/useNewArticleSites.ts @@ -0,0 +1,65 @@ +import { computed, ref } from 'vue' +import { useSiteLanguages } from '@/composables/useSiteLanguages' +import { useSites, type Site } from '@/composables/useSites' +import { enabledLanguages } from '@/lib/translationSource' +import type { SiteLanguage, SiteLanguages } from '@/lib/siteLanguages' + +const MULTILINGUAL_MINIMUM = 2 + +export function useNewArticleSites() { + const { listAllSites } = useSites() + const { getLanguages } = useSiteLanguages() + + const sites = ref<Site[]>([]) + const siteKey = ref<string | undefined>(undefined) + const languages = ref<SiteLanguage[] | null>(null) + const defaultLang = ref<string | null>(null) + const lang = ref<string | undefined>(undefined) + + const site = computed(() => sites.value.find((entry) => entry.key === siteKey.value)) + const offered = computed(() => enabledLanguages(languages.value ?? []).map((l) => l.lang)) + const multilingual = computed(() => offered.value.length >= MULTILINGUAL_MINIMUM) + + async function loadSites() { + try { + const list = await listAllSites() + sites.value = Array.isArray(list) ? list : [] + } catch { + sites.value = [] + } + siteKey.value = (sites.value.find((entry) => entry.is_default) ?? sites.value[0])?.key + } + + async function loadLanguages() { + languages.value = null + defaultLang.value = null + const key = siteKey.value + if (!key) { + return + } + let loaded: SiteLanguages + try { + loaded = await getLanguages(key) + } catch { + return + } + if (key !== siteKey.value) { + return + } + languages.value = loaded.languages + defaultLang.value = loaded.default + } + + return { + sites, + siteKey, + site, + languages, + defaultLang, + lang, + offered, + multilingual, + loadSites, + loadLanguages, + } +} diff --git a/web/src/composables/useVersionSwitch.ts b/web/src/composables/useVersionSwitch.ts index 4901cf7..00ee267 100644 --- a/web/src/composables/useVersionSwitch.ts +++ b/web/src/composables/useVersionSwitch.ts @@ -41,7 +41,7 @@ export function useVersionSwitch(options: VersionSwitchOptions) { })) } - async function open(row: VersionRow) { + async function open(row: VersionRow, announcement?: string) { opening.value = true loading.start() try { @@ -52,7 +52,7 @@ export function useVersionSwitch(options: VersionSwitchOptions) { if (failure) { throw failure } - announce(t('Editing {0} version', [row.name])) + announce(announcement ?? t('Editing {0} version', [row.name])) } catch { options.cancelLeave() clearStashedArticle() @@ -87,14 +87,14 @@ export function useVersionSwitch(options: VersionSwitchOptions) { return choice === 'save' && (await options.save()) === 'saved' } - async function choose(row: VersionRow) { + async function choose(row: VersionRow, announcement?: string) { if (row.current || row.addTranslation || row.articleId === null || opening.value) { return } if (options.hasUnsavedChanges() && !(await settleUnsaved(row))) { return } - await open(row) + await open(row, announcement) } return { choose, loadingVisible: loading.visible } diff --git a/web/src/i18n/messages.ts b/web/src/i18n/messages.ts index 9344fe0..2696ecd 100644 --- a/web/src/i18n/messages.ts +++ b/web/src/i18n/messages.ts @@ -1,11 +1,13 @@ import { articleListMessages } from './articleListMessages' import { editorMessages } from './editorMessages' import { localizationMessages } from './localizationMessages' +import { translationFlowMessages } from './translationFlowMessages' export const messages = { ...localizationMessages, ...articleListMessages, ...editorMessages, + ...translationFlowMessages, 'Not found': 'Introuvable', 'Edit article': 'Modifier l’article', unselected: 'désélectionnée', diff --git a/web/src/i18n/metaMessages.spec.ts b/web/src/i18n/metaMessages.spec.ts index 30f7c32..490dab4 100644 --- a/web/src/i18n/metaMessages.spec.ts +++ b/web/src/i18n/metaMessages.spec.ts @@ -19,6 +19,7 @@ const SOURCES = [ 'components/SeoAdvancedSection.vue', 'components/SeoSocialSection.vue', 'components/SiteSelect.vue', + 'components/SiteMoveField.vue', 'composables/useArticleActions.ts', 'composables/useArticleRowActions.ts', 'composables/useEditorImages.ts', diff --git a/web/src/i18n/translationFlowMessages.spec.ts b/web/src/i18n/translationFlowMessages.spec.ts new file mode 100644 index 0000000..5e92707 --- /dev/null +++ b/web/src/i18n/translationFlowMessages.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { messages } from './messages' +import { translationFlowMessages } from './translationFlowMessages' + +const NUMBERED = /\{\d+\}/g + +describe('Translation flow messages', () => { + it.each(Object.entries(translationFlowMessages))('translates "%s"', (english, french) => { + expect(french).toMatch(/\S/) + expect(french).not.toBe(english) + expect([...french.matchAll(NUMBERED)].map((match) => match[0]).sort()).toEqual( + [...english.matchAll(NUMBERED)].map((match) => match[0]).sort(), + ) + expect((messages as Record<string, string>)[english]).toBe(french) + }) + + it('says article, never story', () => { + expect(Object.keys(translationFlowMessages).join(' ')).not.toMatch(/\bstory\b/i) + }) +}) diff --git a/web/src/i18n/translationFlowMessages.ts b/web/src/i18n/translationFlowMessages.ts new file mode 100644 index 0000000..8330d30 --- /dev/null +++ b/web/src/i18n/translationFlowMessages.ts @@ -0,0 +1,155 @@ +export const translationFlowMessages = { + 'Start empty': 'Commencer vide', + 'Only the title. Freshness shows No source until you pick a tracked source.': + 'Seulement le titre. La fraîcheur indique Aucune source tant que vous n’avez pas choisi une source suivie.', + 'Copy from {0}': 'Copier depuis {0}', + "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.": + 'Copie le corps, l’extrait, les textes de recherche et de partage, les textes alternatifs des images et les auteurs. Les images sont réutilisées, les fichiers ne sont pas dupliqués. La version démarre à relire.', + 'Creates a {0} draft of "{1}". It isn\'t published.': + 'Crée un brouillon en {0} de « {1} ». Il n’est pas publié.', + 'Creates a draft of "{0}" in the language you choose. It isn\'t published.': + 'Crée un brouillon de « {0} » dans la langue que vous choisissez. Il n’est pas publié.', + 'Title in {0}': 'Titre en {0}', + 'Copying text is a starting point: it stays in {0} until you translate it.': + 'Copier le texte est un point de départ : il reste en {0} jusqu’à ce que vous le traduisiez.', + 'Create {0} draft': 'Créer le brouillon en {0}', + '{0} was disabled on this site.': '{0} a été désactivée sur ce site.', + 'Add a translation': 'Ajouter une traduction', + 'Source version': 'Version source', + 'You choose which version to copy from. It is also the source tracked for review. Access is checked again when you create the draft.': + 'Vous choisissez la version à copier. C’est aussi la source suivie pour la relecture. L’accès est vérifié à nouveau à la création du brouillon.', + 'Choose a version': 'Choisir une version', + 'Starting content': 'Contenu de départ', + 'Someone else saved this version': 'Quelqu’un d’autre a enregistré cette version', + 'Reload theirs?': 'Recharger sa version ?', + "Someone else saved this version at {0}. Your changes aren't saved. They stay here until you choose.": + 'Quelqu’un d’autre a enregistré cette version à {0}. Vos modifications ne sont pas enregistrées. Elles restent ici jusqu’à votre choix.', + 'This discards your unsaved changes.': 'Cela abandonne vos modifications non enregistrées.', + "Couldn't replace theirs": 'Impossible de remplacer sa version', + 'Someone else saved this version again at {0}, before your save went through. Nothing was overwritten. Your changes are still here.': + 'Quelqu’un d’autre a de nouveau enregistré cette version à {0}, avant votre enregistrement. Rien n’a été écrasé. Vos modifications sont toujours ici.', + 'Nothing was overwritten. Your changes are still here. Try again.': + 'Rien n’a été écrasé. Vos modifications sont toujours ici. Réessayez.', + 'Your session ended. These changes were kept in this browser.': + 'Votre session a pris fin. Ces modifications ont été conservées dans ce navigateur.', + 'Keep my changes': 'Garder mes modifications', + 'Nothing is sent. Your changes stay in the editor.': + 'Rien n’est envoyé. Vos modifications restent dans l’éditeur.', + 'Download my version': 'Télécharger ma version', + 'Saves your title and body as a text file. Nothing is sent.': + 'Enregistre votre titre et votre corps dans un fichier texte. Rien n’est envoyé.', + 'Reload theirs': 'Recharger sa version', + 'Shows their version. You confirm first.': 'Affiche sa version. Vous confirmez d’abord.', + 'Save over theirs': 'Écraser sa version', + 'Replaces the title, body and search fields of this version only. Other languages are untouched.': + 'Remplace le titre, le corps et les champs de recherche de cette version seulement. Les autres langues ne changent pas.', + 'Reload and discard my changes': 'Recharger et abandonner mes modifications', + "You can't move this article": 'Vous ne pouvez pas déplacer cet article', + 'Moving to {0}…': 'Déplacement vers {0}…', + 'Move to {0}?': 'Déplacer vers {0} ?', + 'All {0} versions move together. Keep this page open.': + 'Les {0} versions se déplacent ensemble. Gardez cette page ouverte.', + 'Keep this page open.': 'Gardez cette page ouverte.', + 'This moves every version together. Nothing is split.': + 'Toutes les versions se déplacent ensemble. Rien n’est séparé.', + "Couldn't move the article": 'Impossible de déplacer l’article', + '{0} enables {1} only.': '{0} n’active que {1}.', + "{0} doesn't enable every language in this article": + '{0} n’active pas toutes les langues de cet article', + 'Enable {0} on {1} first.': 'Activez d’abord {0} sur {1}.', + 'Languages disabled on {0} count too.': 'Les langues désactivées sur {0} comptent aussi.', + 'Nothing was moved.': 'Rien n’a été déplacé.', + 'The {0} address "{1}" is already used by another {0} article on {2}.': + 'L’adresse {0} « {1} » est déjà utilisée par un autre article en {0} sur {2}.', + 'An address is already used on {0}': 'Une adresse est déjà utilisée sur {0}', + 'Change it before moving. Nothing was moved.': + 'Changez-la avant de déplacer. Rien n’a été déplacé.', + 'Moving…': 'Déplacement…', + 'Moves all language versions of this article.': + 'Déplace toutes les versions linguistiques de cet article.', + 'Addresses and links': 'Adresses et liens', + 'Each version keeps its address. Links from the old site to it will stop working. RenewCMS does not create redirects.': + 'Chaque version garde son adresse. Les liens de l’ancien site vers elle cesseront de fonctionner. RenewCMS ne crée pas de redirections.', + Moving: 'Déplacement', + 'This takes a few seconds.': 'Cela prend quelques secondes.', + 'Moving needs edit permission on every version': + 'Déplacer exige la permission de modifier chaque version', + 'Ask an owner to move it. Nothing was changed.': + 'Demandez à un propriétaire de le déplacer. Rien n’a été modifié.', + 'Nothing was moved. Try again.': 'Rien n’a été déplacé. Réessayez.', + 'Moves the article to the site you choose.': + 'Déplace l’article vers le site que vous choisissez.', + 'Move article…': 'Déplacer l’article…', + 'Starts an article in this language.': 'Démarre un article dans cette langue.', + 'Translation of an existing article': 'Traduction d’un article existant', + 'Uses the same rules as Add translation.': 'Suit les mêmes règles que Ajouter une traduction.', + '{0} only offers {1}, so the language was reset from {2}.': + '{0} ne propose que {1}, la langue a donc été réinitialisée depuis {2}.', + 'Default for {0}.': 'Par défaut pour {0}.', + 'No {0} version yet. Copies from {1}': 'Pas encore de version en {0}. Copie depuis {1}', + 'Create a translation of an article you can read.': + 'Créez une traduction d’un article que vous pouvez lire.', + 'Give the article a title. You can write the rest in the editor.': + 'Donnez un titre à l’article. Vous écrirez le reste dans l’éditeur.', + 'Create article': 'Créer l’article', + '{0} draft created': 'Brouillon en {0} créé', + 'Search articles': 'Rechercher des articles', + 'Choose an article': 'Choisir un article', + 'No article is missing this language.': 'Aucun article ne manque dans cette langue.', + 'New article or translation': 'Nouvel article ou traduction', + '{0} already exists for this article': '{0} existe déjà pour cet article', + 'Open it to continue. Your entries are kept here.': + 'Ouvrez-la pour continuer. Votre saisie est conservée ici.', + "{0} can't be created for this article": '{0} ne peut pas être créée pour cet article', + 'Try a different language, or ask an owner. Your entries are kept.': + 'Essayez une autre langue ou demandez à un propriétaire. Votre saisie est conservée.', + '{0} was disabled': '{0} a été désactivée', + '{0} 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.': + '{0} a été désactivée sur ce site pendant votre saisie. Demandez à un propriétaire de la réactiver ou choisissez une autre langue. Votre saisie est conservée.', + "The source isn't available to you": 'La source n’est pas disponible pour vous', + 'You can no longer read the version you chose. Choose another source you can read, or start empty. Your title is kept.': + 'Vous ne pouvez plus lire la version choisie. Choisissez une autre source lisible ou commencez vide. Votre titre est conservé.', + "Couldn't create the translation": 'Impossible de créer la traduction', + 'Your entries are kept. Try again.': 'Votre saisie est conservée. Réessayez.', + 'Open {0} version': 'Ouvrir la version en {0}', + 'Your {0} title and body are saved as a text file. Your changes are still in the editor.': + 'Votre titre et votre corps en {0} sont enregistrés dans un fichier texte. Vos modifications sont toujours dans l’éditeur.', + 'Your title and body are saved as a text file. Your changes are still in the editor.': + 'Votre titre et votre corps sont enregistrés dans un fichier texte. Vos modifications sont toujours dans l’éditeur.', + "Couldn't delete the whole article": 'Impossible de supprimer tout l’article', + 'Some versions were deleted. Open the language menu to see what is left.': + 'Certaines versions ont été supprimées. Ouvrez le menu des langues pour voir ce qui reste.', + 'Delete the {0} version?': 'Supprimer la version en {0} ?', + '"{0}" will be deleted. Other versions stay.': + '« {0} » sera supprimé. Les autres versions restent.', + '{0} version deleted': 'Version en {0} supprimée', + '"{0}" is gone. Other versions stay.': '« {0} » a disparu. Les autres versions restent.', + "You can't delete this article": 'Vous ne pouvez pas supprimer cet article', + 'Deleting an article needs delete permission on every version. You can still delete the version you are editing.': + 'Supprimer un article exige la permission de supprimer chaque version. Vous pouvez toujours supprimer la version que vous modifiez.', + 'Delete the only version?': 'Supprimer l’unique version ?', + '{0} is the only version of this article. Deleting it deletes the article.': + '{0} est la seule version de cet article. La supprimer supprime l’article.', + "This deletes the article and every version you can read. It can't be undone.": + 'Cela supprime l’article et chaque version que vous pouvez lire. C’est irréversible.', + 'Moved to {0}': 'Déplacé vers {0}', + 'This page may be out of date. Reload it to see the latest.': + 'Cette page n’est peut-être plus à jour. Rechargez-la pour voir la dernière version.', + 'All {0} versions moved together.': 'Les {0} versions ont été déplacées ensemble.', + 'The article moved.': 'L’article a été déplacé.', + '{0} · published': '{0} · publiée', + '{0} · draft': '{0} · brouillon', + '{0} and {1} more will be copied. You can change them in the editor.': + '{0} et {1} autres seront copiés. Vous pourrez les modifier dans l’éditeur.', + '{0} will be copied. You can change them in the editor.': + '{0} seront copiés. Vous pourrez les modifier dans l’éditeur.', + '1 inline image is reused.': '1 image dans le texte est réutilisée.', + '{0} inline images are reused.': '{0} images dans le texte sont réutilisées.', + 'Cover image is reused.': 'L’image de couverture est réutilisée.', + 'Cover image and 1 inline image are reused.': + 'L’image de couverture et 1 image dans le texte sont réutilisées.', + 'Cover image and {0} inline images are reused.': + 'L’image de couverture et {0} images dans le texte sont réutilisées.', + 'Alternative text is copied and stays in {0} until you translate it.': + 'Le texte alternatif est copié et reste en {0} jusqu’à ce que vous le traduisiez.', +} diff --git a/web/src/lib/__tests__/addTranslationLocation.spec.ts b/web/src/lib/__tests__/addTranslationLocation.spec.ts new file mode 100644 index 0000000..80a6a6d --- /dev/null +++ b/web/src/lib/__tests__/addTranslationLocation.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { addTranslationLocation } from '@/lib/addTranslationLocation' + +describe('The list row menu leads to the Add translation dialog', () => { + it('names the language when exactly one is missing', () => { + expect(addTranslationLocation({ id: 12, missing_langs: ['de'] })).toEqual({ + path: '/article/12/edit', + query: { dialog: 'add-translation', lang: 'de' }, + }) + }) + + it('names no language when several are missing', () => { + expect(addTranslationLocation({ id: 12, missing_langs: ['de', 'ar'] })).toEqual({ + path: '/article/12/edit', + query: { dialog: 'add-translation' }, + }) + }) + + it('uses the language of the chip that was activated', () => { + expect(addTranslationLocation({ id: 12, missing_langs: ['de', 'ar'] }, 'ar')).toEqual({ + path: '/article/12/edit', + query: { dialog: 'add-translation', lang: 'ar' }, + }) + }) +}) diff --git a/web/src/lib/__tests__/languageAfterSiteChange.spec.ts b/web/src/lib/__tests__/languageAfterSiteChange.spec.ts new file mode 100644 index 0000000..eab145a --- /dev/null +++ b/web/src/lib/__tests__/languageAfterSiteChange.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { languageAfterSiteChange } from '@/lib/languageAfterSiteChange' + +describe('languageAfterSiteChange', () => { + it('keeps the language the new site still offers', () => { + expect(languageAfterSiteChange('fr', ['en', 'fr'], 'en')).toEqual({ + lang: 'fr', + replaced: false, + }) + }) + + it('falls back to the default and reports the replacement', () => { + expect(languageAfterSiteChange('de', ['en'], 'en')).toEqual({ + lang: 'en', + replaced: true, + }) + }) + + it('picks the default without a replacement when nothing was chosen', () => { + expect(languageAfterSiteChange(undefined, ['en', 'fr'], 'en')).toEqual({ + lang: 'en', + replaced: false, + }) + }) + + it('leaves the language empty when the site has no default', () => { + expect(languageAfterSiteChange('de', [], null)).toEqual({ + lang: undefined, + replaced: true, + }) + }) +}) diff --git a/web/src/lib/__tests__/translationFailure.spec.ts b/web/src/lib/__tests__/translationFailure.spec.ts new file mode 100644 index 0000000..57a5041 --- /dev/null +++ b/web/src/lib/__tests__/translationFailure.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { ApiError } from '@/lib/apiError' +import { classifyWithLanguages } from '@/lib/translationFailure' +import type { SiteLanguage } from '@/lib/siteLanguages' + +function row(lang: string, enabled: boolean) { + return { lang, enabled } as SiteLanguage +} + +const unavailable = new ApiError(409, undefined, 'language_unavailable') + +describe('classifyWithLanguages', () => { + it('reads hidden while the language is still enabled', async () => { + const outcome = await classifyWithLanguages(unavailable, 'de', async () => [ + row('de', true), + ]) + + expect(outcome).toBe('hidden') + }) + + it('reads langOff once the language is disabled', async () => { + const outcome = await classifyWithLanguages(unavailable, 'de', async () => [ + row('de', false), + ]) + + expect(outcome).toBe('langOff') + }) + + it('assumes the language is still enabled when the languages cannot be read', async () => { + const outcome = await classifyWithLanguages(unavailable, 'de', async () => { + throw new Error('offline') + }) + + expect(outcome).toBe('hidden') + }) + + it('does not read the languages for other failures', async () => { + let reads = 0 + const outcome = await classifyWithLanguages(new ApiError(500), 'de', async () => { + reads += 1 + return [] + }) + + expect(outcome).toBe('failed') + expect(reads).toBe(0) + }) +}) diff --git a/web/src/lib/addTranslationLocation.ts b/web/src/lib/addTranslationLocation.ts new file mode 100644 index 0000000..233be7d --- /dev/null +++ b/web/src/lib/addTranslationLocation.ts @@ -0,0 +1,15 @@ +import type { RouteLocationRaw } from 'vue-router' + +export const ADD_TRANSLATION_DIALOG = 'add-translation' + +export function addTranslationLocation( + item: { id: number; missing_langs?: string[] }, + lang?: string, +): RouteLocationRaw { + const missing = item.missing_langs ?? [] + const named = lang ?? (missing.length === 1 ? missing[0] : undefined) + return { + path: `/article/${item.id}/edit`, + query: { dialog: ADD_TRANSLATION_DIALOG, ...(named ? { lang: named } : {}) }, + } +} diff --git a/web/src/lib/apiError.ts b/web/src/lib/apiError.ts index 6f9f911..5008fac 100644 --- a/web/src/lib/apiError.ts +++ b/web/src/lib/apiError.ts @@ -23,6 +23,14 @@ export const KNOWN_ERROR_CODES = [ 'wrong_password', 'last_admin', 'account_not_deletable', + 'revision_conflict', + 'language_exists', + 'language_unavailable', + 'language_not_enabled', + 'source_unavailable', + 'site_not_found', + 'setup_required', + 'slug_conflict', ] as const export type ApiErrorCode = (typeof KNOWN_ERROR_CODES)[number] @@ -32,6 +40,10 @@ export interface ApiErrorDetails { permission?: string escalationKey?: string lastRole?: string + existingArticleId?: number + currentRevision?: number + missingLanguages?: string[] + conflictingArticles?: number[] } const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.' @@ -55,6 +67,10 @@ export class ApiError extends Error { permission?: string escalationKey?: string lastRole?: string + existingArticleId?: number + currentRevision?: number + missingLanguages?: string[] + conflictingArticles?: number[] constructor(status: number, retryAfter?: number, body?: string, details: ApiErrorDetails = {}) { super(`Request failed with status ${status}`) @@ -65,5 +81,9 @@ export class ApiError extends Error { this.permission = details.permission this.escalationKey = details.escalationKey this.lastRole = details.lastRole + this.existingArticleId = details.existingArticleId + this.currentRevision = details.currentRevision + this.missingLanguages = details.missingLanguages + this.conflictingArticles = details.conflictingArticles } } diff --git a/web/src/lib/articleTypes.ts b/web/src/lib/articleTypes.ts index ed62a2e..46d8262 100644 --- a/web/src/lib/articleTypes.ts +++ b/web/src/lib/articleTypes.ts @@ -23,9 +23,11 @@ export interface Article extends Partial<ArticleSeo> { id: number title: string body: string + images?: { id: number }[] is_online: boolean lang?: string story_id?: number + revision?: number capabilities: ArticleCapability[] translations?: CoverageEntry[] site_id?: number diff --git a/web/src/lib/bodyPlainText.ts b/web/src/lib/bodyPlainText.ts new file mode 100644 index 0000000..e42738d --- /dev/null +++ b/web/src/lib/bodyPlainText.ts @@ -0,0 +1,16 @@ +const HTML_MIME = 'text/html' +const BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, pre' +const BLANK_LINE = '\n\n' + +export function bodyPlainText(html: string) { + const document = new DOMParser().parseFromString(html, HTML_MIME) + const blocks = Array.from(document.body.querySelectorAll(BLOCK_SELECTOR)) + .filter((block) => !block.querySelector(BLOCK_SELECTOR)) + .map((block) => (block.textContent ?? '').trim()) + .filter((text) => text !== '') + return blocks.length > 0 ? blocks.join(BLANK_LINE) : (document.body.textContent ?? '').trim() +} + +export function conflictDownloadText(title: string, html: string) { + return `${title}${BLANK_LINE}${bodyPlainText(html)}\n` +} diff --git a/web/src/lib/clockTime.ts b/web/src/lib/clockTime.ts new file mode 100644 index 0000000..5407f27 --- /dev/null +++ b/web/src/lib/clockTime.ts @@ -0,0 +1,5 @@ +export function clockOf(date: Date) { + const hours = date.getHours().toString().padStart(2, '0') + const minutes = date.getMinutes().toString().padStart(2, '0') + return `${hours}:${minutes}` +} diff --git a/web/src/lib/editorNotices.ts b/web/src/lib/editorNotices.ts index 2a20a2d..8762d24 100644 --- a/web/src/lib/editorNotices.ts +++ b/web/src/lib/editorNotices.ts @@ -1,4 +1,5 @@ import { t } from '@/i18n' +import { clockOf } from '@/lib/clockTime' import type { Notice } from '@/lib/editorActions' export interface EditorNotice { @@ -20,12 +21,6 @@ export interface NoticeInput { onlineNotice: boolean } -function clockOf(date: Date) { - const hours = date.getHours().toString().padStart(2, '0') - const minutes = date.getMinutes().toString().padStart(2, '0') - return `${hours}:${minutes}` -} - function restoredNotice(input: NoticeInput, restoredAt: Date): EditorNotice { return { key: 'restored', diff --git a/web/src/lib/editorOverflow.ts b/web/src/lib/editorOverflow.ts index 58c1884..8abd13a 100644 --- a/web/src/lib/editorOverflow.ts +++ b/web/src/lib/editorOverflow.ts @@ -57,8 +57,8 @@ function blocksWholeArticle(context: OverflowContext) { return context.siblings.some((sibling) => !holds(sibling.capabilities, 'delete')) } -function blockedExtra(blocked: boolean): Partial<OverflowItem> { - return blocked ? { disabled: true, reason: t("You can't delete this article.") } : {} +function blockedExtra(blocked: boolean, reason: string): Partial<OverflowItem> { + return blocked ? { disabled: true, reason } : {} } function editItems(context: OverflowContext): OverflowItem[] { @@ -70,12 +70,19 @@ function editItems(context: OverflowContext): OverflowItem[] { t('Move article'), 'i-lucide-arrow-right-left', context, - blockedExtra(blocked), + blockedExtra(blocked, t("You can't delete this article.")), ), ] } function deleteItems(context: OverflowContext): OverflowItem[] { + if (context.language && context.siblings.length === 0) { + return [ + item('deleteArticle', t('Delete article'), 'i-lucide-trash-2', context, { + danger: true, + }), + ] + } const versionItem = item( 'deleteVersion', deleteVersionLabel(context.language), @@ -91,7 +98,7 @@ function deleteItems(context: OverflowContext): OverflowItem[] { versionItem, item('deleteArticle', t('Delete article'), 'i-lucide-trash-2', context, { danger: !blocked, - ...blockedExtra(blocked), + ...blockedExtra(blocked, t("You can't delete this article.")), }), ] } diff --git a/web/src/lib/languageAfterSiteChange.ts b/web/src/lib/languageAfterSiteChange.ts new file mode 100644 index 0000000..0a390e8 --- /dev/null +++ b/web/src/lib/languageAfterSiteChange.ts @@ -0,0 +1,15 @@ +export interface LanguageAfterSiteChange { + lang: string | undefined + replaced: boolean +} + +export function languageAfterSiteChange( + previous: string | undefined, + offered: string[], + defaultLang: string | null, +): LanguageAfterSiteChange { + if (previous !== undefined && offered.includes(previous)) { + return { lang: previous, replaced: false } + } + return { lang: defaultLang ?? undefined, replaced: previous !== undefined } +} diff --git a/web/src/lib/moveArticle.ts b/web/src/lib/moveArticle.ts new file mode 100644 index 0000000..15cb5f6 --- /dev/null +++ b/web/src/lib/moveArticle.ts @@ -0,0 +1,27 @@ +import { t } from '@/i18n' +import type { CoverageEntry } from '@/lib/articleTypes' +import { holds } from '@/lib/editorCapabilities' +import { nameInSentence } from '@/lib/storyList' + +export interface MoveVersion { + name: string + status: string +} + +export type MoveResult = + | { kind: 'moved' } + | { kind: 'languages'; missing: string[]; enabled: string[] | null } + | { kind: 'slug'; conflicts: { language: string; slug: string }[] } + | { kind: 'perm' } + | { kind: 'failed' } + | { kind: 'gone' } + +export function moveVersions(entries: CoverageEntry[]): MoveVersion[] { + return entries.map((entry) => ({ + name: nameInSentence(entry.lang), + status: [ + entry.is_online ? t('Published') : t('Draft'), + ...(holds(entry.capabilities, 'edit') ? [] : [t('Read only')]), + ].join(' · '), + })) +} diff --git a/web/src/lib/storedDraft.ts b/web/src/lib/storedDraft.ts index 2e3e571..8864ca7 100644 --- a/web/src/lib/storedDraft.ts +++ b/web/src/lib/storedDraft.ts @@ -6,6 +6,7 @@ export interface StoredDraft { body?: string meta?: { site_id: number | null; authors: Byline[] } seo?: ArticleSeo + revision?: number mirrored_at: string } @@ -73,6 +74,7 @@ function isStoredDraft(value: unknown): value is StoredDraft { isOptionalString(value.body) && isMeta(value.meta) && isSeo(value.seo) && + (value.revision === undefined || typeof value.revision === 'number') && isMirrorTime(value.mirrored_at) ) } diff --git a/web/src/lib/translationFailure.ts b/web/src/lib/translationFailure.ts new file mode 100644 index 0000000..e1e0786 --- /dev/null +++ b/web/src/lib/translationFailure.ts @@ -0,0 +1,60 @@ +import { ApiError } from '@/lib/apiError' +import { codeOf, statusOf } from '@/lib/directoryErrors' +import type { SiteLanguage } from '@/lib/siteLanguages' +import { enabledLanguages } from '@/lib/translationSource' + +export type TranslationFailure = + | 'exists' + | 'hidden' + | 'langOff' + | 'unreadable' + | 'failed' + | 'gone' + | 'session' + +const NOT_FOUND_ARTICLE = 'Article not found' + +export async function classifyTranslationFailure( + error: unknown, + languageStillEnabled: () => Promise<boolean>, +): Promise<TranslationFailure> { + const status = statusOf(error) + const code = codeOf(error) + if (status === 401) { + return 'session' + } + if (status === 409 && code === 'language_exists') { + return 'exists' + } + if (status === 409 && code === 'language_unavailable') { + return (await languageStillEnabled()) ? 'hidden' : 'langOff' + } + if (status === 422 && code === 'language_not_enabled') { + return 'langOff' + } + if (status === 404 && code === 'source_unavailable') { + return 'unreadable' + } + if (status === 404 && code === NOT_FOUND_ARTICLE) { + return 'gone' + } + return 'failed' +} + +export function classifyWithLanguages( + error: unknown, + lang: string | undefined, + loadLanguages: () => Promise<SiteLanguage[]>, +) { + return classifyTranslationFailure(error, async () => { + try { + return enabledLanguages(await loadLanguages()).some((entry) => entry.lang === lang) + } catch { + return true + } + }) +} + +export function existingArticleOf(error: unknown) { + return error instanceof ApiError ? error.existingArticleId : undefined +} diff --git a/web/src/lib/translationSource.ts b/web/src/lib/translationSource.ts new file mode 100644 index 0000000..1f97461 --- /dev/null +++ b/web/src/lib/translationSource.ts @@ -0,0 +1,68 @@ +import { locale, t } from '@/i18n' +import type { Article, CoverageEntry } from '@/lib/articleTypes' +import { joinNames } from '@/lib/nameList' +import { languageName, type SiteLanguage } from '@/lib/siteLanguages' + +const NAMED_AUTHORS = 5 +const MULTIPLE = 2 + +export function enabledLanguages(languages: SiteLanguage[]) { + return languages.filter((language) => language.enabled) +} + +export function offeredLanguages(languages: SiteLanguage[], readable: CoverageEntry[]) { + const present = new Set(readable.map((entry) => entry.lang)) + return enabledLanguages(languages) + .map((language) => language.lang) + .filter((lang) => !present.has(lang)) +} + +export function defaultSource( + languages: SiteLanguage[], + readable: CoverageEntry[], +): CoverageEntry | undefined { + const preferred = languages.find((language) => language.is_default)?.lang + return readable.find((entry) => entry.lang === preferred) ?? readable[0] +} + +export function sourceLabel(entry: CoverageEntry) { + const name = languageName(entry.lang, locale.value) + return entry.is_online ? t('{0} · published', [name]) : t('{0} · draft', [name]) +} + +export function authorsNote(authors: { name: string }[]) { + if (authors.length < MULTIPLE) { + return null + } + const named = authors.slice(0, NAMED_AUTHORS).map((author) => author.name) + const rest = authors.length - named.length + return rest > 0 + ? t('{0} and {1} more will be copied. You can change them in the editor.', [ + named.join(', '), + rest, + ]) + : t('{0} will be copied. You can change them in the editor.', [joinNames(named)]) +} + +export function imagesNote(source: Pick<Article, 'images' | 'cover_image_id'>, language: string) { + const ids = new Set((source.images ?? []).map((image) => image.id)) + const cover = source.cover_image_id ?? null + if (cover !== null) { + ids.delete(cover) + } + const inline = ids.size + if (cover === null && inline === 0) { + return null + } + const reused = + cover === null + ? inline === 1 + ? t('1 inline image is reused.') + : t('{0} inline images are reused.', [inline]) + : inline === 0 + ? t('Cover image is reused.') + : inline === 1 + ? t('Cover image and 1 inline image are reused.') + : t('Cover image and {0} inline images are reused.', [inline]) + return `${reused} ${t('Alternative text is copied and stays in {0} until you translate it.', [language])}` +} diff --git a/web/src/views/ArticleView.vue b/web/src/views/ArticleView.vue index 9a7abf0..48d7b35 100644 --- a/web/src/views/ArticleView.vue +++ b/web/src/views/ArticleView.vue @@ -18,6 +18,7 @@ import EmptyState from '@/components/EmptyState.vue' import OwnScopeEmpty from '@/components/OwnScopeEmpty.vue' import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue' import NewArticleModal from '@/components/NewArticleModal.vue' +import { addTranslationLocation } from '@/lib/addTranslationLocation' import AppPaginator from '@/components/AppPaginator.vue' import { useArticleFilterOptions } from '@/composables/useArticleFilterOptions' import { useArticleList } from '@/composables/useArticleList' @@ -25,7 +26,7 @@ import { useArticleRowActions } from '@/composables/useArticleRowActions' import { useArticleSites } from '@/composables/useArticleSites' import { useContentAccess } from '@/composables/useContentAccess' import { useIsDesktop } from '@/composables/useIsDesktop' -import type { Article } from '@/composables/useArticles' +import type { Article, StoryItem } from '@/composables/useArticles' import { effectiveSort } from '@/lib/articleListQuery' import { filterChips, type FilterChipKey } from '@/lib/articleFilterChips' import type { ArticleFilterFieldProps } from '@/lib/articleFilterFields' @@ -150,8 +151,8 @@ function openVersion(id: number) { router.push(`/article/${id}/edit`) } -function addTranslation(item: Article) { - openVersion(item.id) +function addTranslation(item: StoryItem, lang?: string) { + router.push(addTranslationLocation(item, lang)) } const menuHandlers: StoryMenuHandlers = { @@ -167,6 +168,10 @@ function openNewArticleModal() { newArticleModalOpen.value = true } +function openExistingVersion(id: number) { + router.push(`/article/${id}/edit`) +} + function onArticleCreated(article: Article) { router.push(`/article/${article.id}/edit`) } @@ -378,6 +383,10 @@ onMounted(() => { @clear="list.clearFilters" /> - <NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" /> + <NewArticleModal + v-model:open="newArticleModalOpen" + @created="onArticleCreated" + @open="openExistingVersion" + /> </DashboardShell> </template> diff --git a/web/src/views/EditorView.vue b/web/src/views/EditorView.vue index 5c41ee1..2116d98 100644 --- a/web/src/views/EditorView.vue +++ b/web/src/views/EditorView.vue @@ -1,7 +1,7 @@ <script setup lang="ts"> import { t } from '@/i18n' -import { computed, onBeforeUnmount, onMounted, ref } from 'vue' +import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { createReusableTemplate } from '@vueuse/core' import { useRoute, useRouter } from 'vue-router' import { useLocalizedToast } from '@/i18n/presentation' @@ -33,13 +33,18 @@ import { useArticleDraft, type SaveOutcome } from '@/composables/useArticleDraft import { useArticleLifecycle } from '@/composables/useArticleLifecycle' import { useEditorPublishing } from '@/composables/useEditorPublishing' import { useEditorVersions } from '@/composables/useEditorVersions' +import { useDirectoryToasts } from '@/composables/useDirectoryToasts' +import { useEditorConflict } from '@/composables/useEditorConflict' +import { useEditorDeletion } from '@/composables/useEditorDeletion' +import { announce } from '@/composables/useLiveAnnouncement' +import { useEditorMove } from '@/composables/useEditorMove' +import { useEditorTranslation } from '@/composables/useEditorTranslation' import { useVersionSwitch } from '@/composables/useVersionSwitch' import { useEditorFocus } from '@/composables/useEditorFocus' import { useEditorImages } from '@/composables/useEditorImages' import { useEditorSeoPanel } from '@/composables/useEditorSeoPanel' import { useSeoDismissals } from '@/composables/useSeoDismissals' import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard' -import { useDirectoryToasts } from '@/composables/useDirectoryToasts' import { useSites, type Site } from '@/composables/useSites' import { toBylines } from '@/lib/articleMeta' import { readMetaSectionOpen, storeMetaSectionOpen } from '@/lib/metaSectionMemory' @@ -50,6 +55,7 @@ import { bodyCounts } from '@/lib/bodyCounts' import { useCanManageVariants } from '@/composables/useCanManageVariants' import { planActions, type SaveMood } from '@/lib/editorActions' import { holds } from '@/lib/editorCapabilities' +import type { VersionRow } from '@/lib/editorVersions' import { titleLabel } from '@/lib/editorLabels' import { editorNotices } from '@/lib/editorNotices' import { overflowItems } from '@/lib/editorOverflow' @@ -188,7 +194,17 @@ const address = computed( () => `${hostOf(siteOfArticle.value?.base_url ?? '')}/${addressSlug.value}`, ) +let ignoreEditorEcho = false + +function ignoreNextEditorEcho() { + ignoreEditorEcho = true + nextTick(() => nextTick(() => (ignoreEditorEcho = false))) +} + function editBody(newBody: string) { + if (ignoreEditorEcho) { + return + } draft.edit(newBody) publishing.clearPublishFailure() check.schedule() @@ -229,6 +245,7 @@ async function loadArticle() { }, seoOf(result), result.title, + result.revision, ) await versions.loadLanguages() loadState.value = 'loaded' @@ -313,7 +330,9 @@ const overflow = computed(() => unpublishInMenu: plan.value.unpublish && !wideScreen.value, handlers: { unpublish: handleUnpublish, - deleteVersion: handleDelete, + deleteVersion: deletion.deleteVersion, + deleteArticle: deletion.deleteArticle, + move: movement.open, }, }), ) @@ -337,9 +356,23 @@ function announceSaved() { })) } -async function runSave(options: { announce: boolean } = { announce: true }): Promise<SaveOutcome> { +interface SaveRun { + announce: boolean + revision?: number + conflictDialog?: boolean +} + +async function runSave(options: SaveRun = { announce: true }): Promise<SaveOutcome> { const hadTitleEdit = draft.title.value !== article.value?.title - const outcome = await draft.save() + const outcome = await draft.save({ revision: options.revision }) + if (outcome === 'conflict' && options.conflictDialog !== false) { + if (await conflict.begin()) { + return outcome + } + draft.markFailed() + titleSaveFailed.value = hadTitleEdit + return 'error' + } if (outcome === 'saved') { titleSaveFailed.value = false syncSavedTitle() @@ -351,10 +384,6 @@ async function runSave(options: { announce: boolean } = { announce: true }): Pro } titleSaveFailed.value = outcome === 'error' && hadTitleEdit revealSaveFailure(outcome) - if (outcome === 'site-gone') { - directoryToasts.siteGone() - sites.value = await fetchSites() - } if (outcome === 'author-gone') { directoryToasts.authorGone() } @@ -450,6 +479,120 @@ const { choose: chooseVersion, loadingVisible: switching } = useVersionSwitch({ }, }) +const REFETCH_ATTEMPTS = 2 + +async function fetchFreshVersion() { + for (let attempt = 1; attempt <= REFETCH_ATTEMPTS; attempt += 1) { + try { + return await request<Article>(`/v1/article/${articleId}?format=html`) + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + markNotFound() + return null + } + } + } + announce(t('This page may be out of date. Reload it to see the latest.')) + return null +} + +async function refreshOpenVersion() { + if (!article.value) { + return + } + const fresh = await fetchFreshVersion() + const current = article.value + if (!fresh || !current) { + return + } + article.value = { + ...current, + site_id: fresh.site_id, + revision: fresh.revision, + updated_at: fresh.updated_at, + translations: fresh.translations, + capabilities: fresh.capabilities, + } + draft.setBaseRevision(fresh.revision) + draft.syncSite(fresh.site_id ?? null) + await versions.loadLanguages().catch(() => undefined) +} + +function applyTheirs(theirs: Article) { + const current = article.value + ignoreNextEditorEcho() + draft.replaceWith(theirs) + if (current) { + article.value = { ...current, ...theirs, translations: current.translations } + versions.markTitle(current.id, theirs.title) + } + check.invalidate() + check.runFresh() +} + +const conflict = useEditorConflict({ + articleId, + draft, + article, + language: () => versions.sentenceName.value, + saveOver: (revision) => runSave({ announce: true, revision, conflictDialog: false }), + onReloaded: applyTheirs, + onGone: markNotFound, + onClosed: (result) => { + if (result === 'gone') { + markNotFound() + return + } + titleField.value?.focus() + }, +}) + +const deletion = useEditorDeletion({ + article, + readable: () => versions.orderedReadable.value, + language: () => versions.sentenceName.value, + classicDelete: handleClassicDelete, + refetch: refreshOpenVersion, + onDeleted: () => { + leaving.value = true + draft.discardStored() + }, +}) + +const movement = useEditorMove({ + article, + sites, + readable: () => versions.orderedReadable.value, + multilingual: () => versions.multilingual.value, + onMoved: refreshOpenVersion, + onGone: markNotFound, + refreshSites: async () => { + sites.value = await fetchSites() + }, +}) + +const translation = useEditorTranslation({ + article, + loaded: computed(() => loadState.value === 'loaded'), + languages: () => versions.siteLanguages.value, + refetchLanguages: versions.refreshLanguages, + switchTo: (row, announcement) => chooseVersion(row, announcement), + onGone: markNotFound, +}) + +function openMoveFromDetails() { + detailsOpen.value = false + movement.open() +} + +function onChooseRow(row: VersionRow) { + if (row.addTranslation) { + translation.open(row.lang) + return + } + chooseVersion(row) +} + function handlePublish() { return versions.multilingual.value ? publishing.publish() : setOnline(true) } @@ -477,7 +620,7 @@ function handlePrimary() { return undefined } -async function handleDelete() { +async function handleClassicDelete() { const current = article.value if (!current) { return @@ -549,7 +692,7 @@ useUnsavedChangesGuard({ :authors="draft.meta.value.authors" :remembered="remembered" :sheet="sheet" - @update:site-id="draft.editMeta({ siteId: $event })" + @move="openMoveFromDetails" @update:authors="draft.editMeta({ authors: $event })" @update:remembered="rememberMetaSection" /> @@ -600,7 +743,7 @@ useUnsavedChangesGuard({ :rows="versions.rows.value" :name="versions.displayName.value" :code="versions.tag.value.toUpperCase()" - @choose="chooseVersion" + @choose="onChooseRow" /> <LocaleBadge :tone="article.is_online ? 'success' : 'neutral'" diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue index 1dd1570..60f040c 100644 --- a/web/src/views/HomeView.vue +++ b/web/src/views/HomeView.vue @@ -122,6 +122,10 @@ function openNewArticleModal() { newArticleModalOpen.value = true } +function openExistingVersion(id: number) { + router.push(`/article/${id}/edit`) +} + function onArticleCreated(article: Article) { router.push(`/article/${article.id}/edit`) toast.add(() => ({ @@ -369,6 +373,10 @@ onMounted(loadArticles) </div> </div> - <NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" /> + <NewArticleModal + v-model:open="newArticleModalOpen" + @created="onArticleCreated" + @open="openExistingVersion" + /> </DashboardShell> </template> diff --git a/web/src/views/__tests__/ArticleLocalization.spec.ts b/web/src/views/__tests__/ArticleLocalization.spec.ts index dabc8c1..9101d3a 100644 --- a/web/src/views/__tests__/ArticleLocalization.spec.ts +++ b/web/src/views/__tests__/ArticleLocalization.spec.ts @@ -455,6 +455,21 @@ describe('loc.l7.rowMenu, Row menu', () => { expect(menuLabels()).toEqual(expected) }) + it('opens the editor with the Add translation dialog and no language when several are missing', async () => { + await mountMeta('articles', '/article?site=journal&size=5', { backend: backendFor() }) + await openMenu('Winter checklist for timber windows') + Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')) + .find((item) => textOf(item).startsWith('Add translation'))! + .click() + await flushPromises() + await vi.waitFor(() => + expect(router.currentRoute.value.path).toMatch(/^\/article\/\d+\/edit$/), + ) + + expect(router.currentRoute.value.query.dialog).toBe('add-translation') + expect(router.currentRoute.value.query.lang).toBeUndefined() + }) + it('hides Add translation on an article that has every language', async () => { await mountMeta('articles', '/article?site=journal&size=5', { backend: backendFor() }) await openMenu('How we price a bathroom renovation') diff --git a/web/src/views/__tests__/EditorAddTranslation.spec.ts b/web/src/views/__tests__/EditorAddTranslation.spec.ts new file mode 100644 index 0000000..2ae06c1 --- /dev/null +++ b/web/src/views/__tests__/EditorAddTranslation.spec.ts @@ -0,0 +1,544 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import router from '@/router' +import { initializeLocale } from '@/i18n' +import LocaleSelect from '@/components/localization/LocaleSelect.vue' +import { NetworkError } from '@/composables/useApi' +import { + callsTo, + deferred, + dialogBox, + dialogText, + failWith, + kit, + press, + settle, + typeInto, + unmountRoute, +} from './localizationKit' +import { EDIT_ONLY, IDS, articleOf, mountEditor, type EditorFixture } from './editorKit' + +vi.mock('@/composables/useApi', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/composables/useApi')>() + return { ...actual, useApi: vi.fn() } +}) + +const DIALOG = '/article/12/edit?dialog=add-translation' +const CREATED_ID = 14 +const FOUR = ['en', 'fr', 'ar'] as const + +beforeEach(() => { + initializeLocale() + sessionStorage.clear() +}) + +afterEach(unmountRoute) + +function created() { + return { + ...articleOf({ lang: 'fr' }, 'de'), + id: CREATED_ID, + title: 'Kosten', + is_online: false, + revision: 1, + } +} + +function open(fixture: EditorFixture = {}, url = `${DIALOG}&lang=de`) { + return mountEditor( + { + translations: [...FOUR], + ...fixture, + extra: { + 'POST /v1/article/12/translations': created(), + 'GET /v1/article/14': created(), + ...fixture.extra, + } as never, + }, + url, + ) +} + +function field(label: string) { + return kit + .wrapper!.findAllComponents(LocaleSelect) + .find((component) => component.props('ariaLabel') === label) +} + +function dialogTitleInput() { + return dialogBox()!.querySelector<HTMLInputElement>('input[type="text"], input:not([type])')! +} + +async function submit(label = 'Create German draft') { + await press(label, dialogBox()!) +} + +async function fillTitle(value = 'Kosten') { + await typeInto(dialogTitleInput(), value) +} + +describe('loc.l4.form: The dialog opens from the URL and from the language menu', () => { + it('opens for German with the title field focused', async () => { + await open() + + expect(dialogText()).toContain('Add a translation') + expect(field('Language')!.props('modelValue')).toBe('de') + expect(document.activeElement).toBe(dialogTitleInput()) + }) + + it('opens with Language empty and the primary disabled when no language is given', async () => { + await open({}, DIALOG) + + expect(field('Language')!.props('modelValue')).toBeUndefined() + const primary = Array.from(dialogBox()!.querySelectorAll('button')).find((button) => + /Create/.test(button.textContent ?? ''), + )! + expect(primary.hasAttribute('disabled')).toBe(true) + }) + + it.each([ + ['French, because a version exists', 'fr'], + ['Spanish, because it is disabled', 'es'], + ])('leaves Language empty for %s', async (_name, lang) => { + await open({}, `${DIALOG}&lang=${lang}`) + + expect(field('Language')!.props('modelValue')).toBeUndefined() + }) + + it('opens no dialog and strips the parameters without create_translation', async () => { + await open({ caps: EDIT_ONLY }) + + expect(dialogBox()).toBeNull() + expect(router.currentRoute.value.fullPath).toBe('/article/12/edit') + }) + + it('closes with the Back button', async () => { + await open({}, '/article/12/edit') + await router.push(`${DIALOG}&lang=de`) + await settle() + + expect(dialogBox()).not.toBeNull() + router.back() + await new Promise((resolve) => setTimeout(resolve, 50)) + await settle() + expect(dialogBox()).toBeNull() + }) +}) + +describe('Offered languages are the enabled languages with no readable version', () => { + it('lists German only, in the site order', async () => { + await open() + + expect(field('Language')!.props('items')).toEqual([{ label: 'German', value: 'de' }]) + }) + + it('lists a language whose version is unreadable to the caller too', async () => { + await open({ translations: ['en', 'fr'] }, `${DIALOG}&lang=de`) + + expect(field('Language')!.props('items')).toEqual([ + { label: 'German', value: 'de' }, + { label: 'Arabic', value: 'ar' }, + ]) + }) +}) + +describe('The form copy, source and Start empty', () => { + it('describes the copy and offers the board fields and footer', async () => { + await open() + const text = dialogText() + + expect(text).toContain( + 'Creates a German draft of "Couts de renovation". It isn\'t published.', + ) + expect(text).toContain('Title in German') + expect(text).toContain( + 'Copying text is a starting point: it stays in English until you translate it.', + ) + expect(text).toContain('Start empty') + expect(text).toContain('Copy from English') + expect(text).toContain( + "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.", + ) + const footer = Array.from(dialogBox()!.querySelectorAll('button')).map((button) => + button.textContent?.trim(), + ) + expect(footer.slice(-2)).toEqual(['Cancel', 'Create German draft']) + }) + + it('picks the default-language version as the source and labels the options', async () => { + await open() + + expect(field('Source version')!.props('modelValue')).toBe(String(IDS.en)) + expect(field('Source version')!.props('items')).toEqual([ + { label: 'English · published', value: '10' }, + { label: 'French · draft', value: '12' }, + { label: 'Arabic · published', value: '13' }, + ]) + }) + + it('hides the source and the copy note on Start empty', async () => { + await open() + kit.wrapper!.findComponent({ name: 'LocaleChoiceCards' }).vm.$emit( + 'update:modelValue', + 'empty', + ) + await settle() + + expect(field('Source version')).toBeUndefined() + expect(dialogText()).not.toContain('Copying text is a starting point') + expect(dialogText()).toContain( + 'Only the title. Freshness shows No source until you pick a tracked source.', + ) + }) +}) + +describe('Title validation runs before any request', () => { + it.each([ + ['nothing', '', 'Enter a title'], + ['"ab"', 'ab', 'Use at least 3 characters'], + ['201 characters', 'x'.repeat(201), 'Use 200 characters or fewer'], + ])('shows the message for %s and sends nothing', async (_name, title, message) => { + await open() + await fillTitle(title) + await submit() + + expect(dialogText()).toContain(message) + expect(callsTo('POST', '/v1/article/12/translations')).toHaveLength(0) + }) + + it('sends the request for 200 emoji', async () => { + await open() + await fillTitle('😀'.repeat(200)) + await submit() + + expect(callsTo('POST', '/v1/article/12/translations')).toHaveLength(1) + }) +}) + +describe('Creating the translation', () => { + it('copies from the chosen source with the open version in the path', async () => { + await open() + field('Source version')!.vm.$emit('update:modelValue', String(IDS.fr)) + await settle() + await fillTitle() + await submit() + + expect(callsTo('POST', '/v1/article/12/translations')[0]!.body).toEqual({ + lang: 'de', + title: 'Kosten', + copy: true, + source_article_id: 12, + }) + }) + + it('sends no source when starting empty', async () => { + await open() + kit.wrapper!.findComponent({ name: 'LocaleChoiceCards' }).vm.$emit( + 'update:modelValue', + 'empty', + ) + await settle() + await fillTitle() + await submit() + + expect(callsTo('POST', '/v1/article/12/translations')[0]!.body).toEqual({ + lang: 'de', + title: 'Kosten', + copy: false, + }) + }) + + it('locks the dialog while creating: Creating…, disabled Cancel, Escape does nothing', async () => { + const gate = deferred<unknown>() + await open({ extra: { 'POST /v1/article/12/translations': () => gate.promise } as never }) + await fillTitle() + await submit() + + const labels = Array.from(dialogBox()!.querySelectorAll('button')).map((button) => ({ + label: button.textContent?.trim(), + disabled: button.hasAttribute('disabled'), + })) + expect(labels).toContainEqual({ label: 'Cancel', disabled: true }) + expect(labels.some((entry) => entry.label === 'Creating…')).toBe(true) + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + bubbles: true, + cancelable: true, + }), + ) + await settle() + expect(dialogBox()).not.toBeNull() + gate.resolve(created()) + await settle() + }) + + it('opens the new version, announces it and lists it in the menu', async () => { + await open() + await fillTitle() + await submit() + + expect(dialogBox()).toBeNull() + expect(router.currentRoute.value.path).toBe('/article/14/edit') + expect(document.body.querySelector('[data-testid="live-announcement"]')?.textContent).toBe( + 'German draft created', + ) + }) + + it('asks before leaving unsaved edits and keeps the open version on Keep editing', async () => { + await open() + await fillTitle() + kit.wrapper!.findComponent({ name: 'ArticleEditor' }).vm.$emit( + 'update:modelValue', + '<p>Edit</p>', + ) + await settle() + await submit() + + expect(dialogText()).toContain('Leave without saving?') + expect(dialogText()).toContain('Discard and open German') + expect(dialogText()).toContain('Save and open German') + await press('Keep editing') + + expect(router.currentRoute.value.path).toBe('/article/12/edit') + }) +}) + +describe('Failures keep the entries', () => { + const exists = failWith(409, 'language_exists', { existingArticleId: 11 }) + const unavailable = failWith(409, 'language_unavailable') as () => never + + async function fail(response: unknown, languages?: EditorFixture['languages']) { + await open({ + languages, + extra: { 'POST /v1/article/12/translations': response } as never, + }) + await fillTitle() + await submit() + } + + function primaryDisabled() { + return Array.from(dialogBox()!.querySelectorAll('button')) + .find((button) => /^Create German draft$/.test(button.textContent?.trim() ?? ''))! + .hasAttribute('disabled') + } + + it('loc.dup.readable: offers Open German version and disables the primary', async () => { + await fail(exists) + + expect(dialogText()).toContain('German already exists for this article') + expect(dialogText()).toContain('Open it to continue. Your entries are kept here.') + expect(dialogText()).toContain('Open German version') + expect(primaryDisabled()).toBe(true) + expect(dialogTitleInput().value).toBe('Kosten') + }) + + it('opens the existing version through the version switch', async () => { + await fail(exists) + kit.calls.length = 0 + await press('Open German version') + + expect(dialogBox()).toBeNull() + expect(router.currentRoute.value.path).toBe('/article/11/edit') + }) + + it('loc.dup.hidden: a neutral error with no Open action while German is still enabled', async () => { + await fail(unavailable) + + expect(dialogText()).toContain("German can't be created for this article") + expect(dialogText()).toContain( + 'Try a different language, or ask an owner. Your entries are kept.', + ) + expect(dialogText()).not.toContain('Open German version') + expect(primaryDisabled()).toBe(true) + }) + + it('loc.l4.langOff: German no longer enabled makes Language read-only with the warning', async () => { + let reads = 0 + let refused = false + await open({ + extra: { + 'POST /v1/article/12/translations': () => { + refused = true + return unavailable() + }, + 'GET /v1/site/journal/languages': () => { + reads += 1 + return { + revision: 'r', + default: 'en', + needs_setup: false, + available: [], + languages: (refused ? ['en', 'fr', 'ar'] : ['en', 'fr', 'de', 'ar']).map( + (lang) => ({ + lang, + enabled: true, + is_default: lang === 'en', + version_count: 1, + published_count: 1, + social_locale: null, + social_locale_state: 'not_set', + }), + ), + } + }, + } as never, + }) + const before = reads + await fillTitle() + await submit() + + expect(reads).toBe(before + 1) + expect(dialogText()).toContain('German was disabled') + expect(dialogText()).toContain('German was disabled on this site.') + expect(primaryDisabled()).toBe(true) + }) + + it('loc.l4.langOff on a 422 language_not_enabled', async () => { + await fail(failWith(422, 'language_not_enabled')) + + expect(dialogText()).toContain( + 'German was disabled on this site while you were filling this in', + ) + }) + + it('loc.l4.unreadable: warns and asks for another source on a 404 source_unavailable', async () => { + await fail(failWith(404, 'source_unavailable')) + + expect(dialogText()).toContain("The source isn't available to you") + expect(dialogText()).toContain('Choose a version') + expect(primaryDisabled()).toBe(true) + expect(dialogTitleInput().value).toBe('Kosten') + }) + + it.each([ + ['a 500', failWith(500, 'Internal server error')], + [ + 'a network failure', + () => { + throw new NetworkError() + }, + ], + ['a 403', failWith(403, 'missing_permission')], + ['a 400', failWith(400, 'The request cannot be processed')], + ])('loc.l4.fail: keeps the entries and offers Try again after %s', async (_name, response) => { + await fail(response) + + expect(dialogText()).toContain("Couldn't create the translation") + expect(dialogText()).toContain('Your entries are kept. Try again.') + expect(dialogText()).toContain('Try again') + expect(dialogTitleInput().value).toBe('Kosten') + }) + + it('shows the same DOM for the three neutral 409 answers', async () => { + await fail(unavailable) + const first = dialogBox()!.innerHTML.replace(/id="[^"]*"/g, '') + await unmountRoute() + await fail(failWith(409, 'language_unavailable', { existingArticleId: undefined })) + const second = dialogBox()!.innerHTML.replace(/id="[^"]*"/g, '') + + expect(second).toBe(first) + }) + + it('closes the dialog and shows Version unavailable when the open version disappears', async () => { + await fail(failWith(404, 'Article not found')) + + expect(dialogBox()).toBeNull() + expect(document.body.textContent).toContain("This version isn't available") + }) +}) + +describe('Recovering from a blocking state', () => { + it('re-enables the primary after choosing another readable source', async () => { + await open({ + extra: { + 'POST /v1/article/12/translations': failWith(404, 'source_unavailable'), + } as never, + }) + await fillTitle() + await submit() + field('Source version')!.vm.$emit('update:modelValue', String(IDS.fr)) + await settle() + + expect(dialogText()).not.toContain("The source isn't available to you") + }) + + it('re-enables the primary after choosing Start empty', async () => { + await open({ + extra: { + 'POST /v1/article/12/translations': failWith(404, 'source_unavailable'), + } as never, + }) + await fillTitle() + await submit() + kit.wrapper!.findComponent({ name: 'LocaleChoiceCards' }).vm.$emit( + 'update:modelValue', + 'empty', + ) + await settle() + + expect(dialogText()).not.toContain("The source isn't available to you") + }) +}) + +describe('The dialog is keyboard operable', () => { + it('closes on Escape, removes the parameters from the URL', async () => { + await open() + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + bubbles: true, + cancelable: true, + }), + ) + await settle() + + expect(dialogBox()).toBeNull() + expect(router.currentRoute.value.fullPath).toBe('/article/12/edit') + }) +}) + +describe('loc.l4.long: Long content shows what is copied', () => { + function source(authors: number, images: number) { + return { + ...articleOf({}, 'en'), + authors: Array.from({ length: authors }, (_, index) => ({ + id: index + 1, + name: `Author ${index + 1}`, + slug: `author-${index + 1}`, + })), + cover_image_id: images > 0 ? 1 : null, + images: Array.from({ length: images }, (_, index) => ({ id: index + 1 })), + } + } + + it('shows no Authors or Images note for one author and no image', async () => { + await open({ extra: { 'GET /v1/article/10': source(1, 0) } as never }) + + expect(dialogText()).not.toContain('Authors') + expect(dialogText()).not.toContain('Cover image') + expect(dialogText()).not.toContain('inline image') + }) + + it('names the first five authors and counts the rest, and counts the images', async () => { + await open({ extra: { 'GET /v1/article/10': source(10, 8) } as never }) + + expect(dialogText()).toContain( + 'Author 1, Author 2, Author 3, Author 4, Author 5 and 5 more will be copied. You can change them in the editor.', + ) + expect(dialogText()).toContain( + 'Cover image and 7 inline images are reused. Alternative text is copied and stays in English until you translate it.', + ) + }) +}) + +describe('A 401 is handled by the global handler', () => { + it('goes to login and keeps nothing about the dialog', async () => { + await open({ extra: { 'POST /v1/article/12/translations': failWith(401) } as never }) + await fillTitle() + await submit() + + expect(sessionStorage.length).toBe(0) + }) +}) diff --git a/web/src/views/__tests__/EditorDeletion.spec.ts b/web/src/views/__tests__/EditorDeletion.spec.ts new file mode 100644 index 0000000..07f8b4e --- /dev/null +++ b/web/src/views/__tests__/EditorDeletion.spec.ts @@ -0,0 +1,313 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import router from '@/router' +import { initializeLocale } from '@/i18n' +import { ApiError, NetworkError } from '@/composables/useApi' +import { + callsTo, + dialogBox, + dialogText, + failWith, + kit, + press, + settle, + text, + toastText, + unmountRoute, +} from './localizationKit' +import { + READ, + articleOf, + editBody, + mountEditor, + openMoreActions, + type EditorFixture, +} from './editorKit' + +vi.mock('@/composables/useApi', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/composables/useApi')>() + return { ...actual, useApi: vi.fn() } +}) + +const ALL = ['en', 'fr', 'de', 'ar'] as const +const gone = null + +beforeEach(() => { + initializeLocale() + sessionStorage.clear() +}) + +afterEach(unmountRoute) + +function menuItem(label: string) { + return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')).find( + (item) => item.textContent?.trim().startsWith(label), + ) +} + +function deletes() { + return kit.calls.filter((call) => call.method === 'DELETE').map((call) => call.path) +} + +function everyDelete(handler: unknown = gone): Record<string, unknown> { + return { + 'DELETE /v1/article/10': handler, + 'DELETE /v1/article/11': handler, + 'DELETE /v1/article/12': handler, + 'DELETE /v1/article/13': handler, + } +} + +function open(fixture: EditorFixture = {}, extra: Record<string, unknown> = {}) { + return mountEditor({ + translations: [...ALL], + ...fixture, + extra: { ...everyDelete(), ...extra } as never, + }) +} + +async function choose(label: string) { + await openMoreActions() + menuItem(label)!.click() + await settle() +} + +describe('Delete scopes: the menu follows what the owner reads', () => { + it('enables both items when four versions are readable', async () => { + await open() + await openMoreActions() + + expect(menuItem('Delete French version')!.getAttribute('aria-disabled')).toBeNull() + expect(menuItem('Delete article')!.getAttribute('aria-disabled')).toBeNull() + }) + + it('shows Delete article alone when only English is readable', async () => { + await open({ lang: 'en', translations: ['en'] }) + await openMoreActions() + + expect(menuItem('Delete article')!.getAttribute('aria-disabled')).toBeNull() + expect(menuItem('Delete English version')).toBeUndefined() + }) + + it('disables Delete article with the reason when 13 cannot be deleted', async () => { + await open({ siblingCaps: { ar: READ } }) + await openMoreActions() + + expect(menuItem('Delete French version')!.getAttribute('aria-disabled')).toBeNull() + expect(menuItem('Delete article')!.getAttribute('aria-disabled')).toBe('true') + expect(menuItem('Delete article')!.textContent).toContain("You can't delete this article.") + }) +}) + +describe('loc.l8.delete: Delete French version', () => { + it('asks with a danger button and deletes only this version', async () => { + await open() + await choose('Delete French version') + + expect(dialogText()).toContain('Delete the French version?') + expect(dialogText()).toContain( + '"Couts de renovation" will be deleted. Other versions stay.', + ) + const buttons = Array.from(dialogBox()!.querySelectorAll('button')).map((button) => + button.textContent?.trim(), + ) + expect(buttons.slice(-2)).toEqual(['Cancel', 'Delete French version']) + await press('Delete French version', dialogBox()!) + + expect(deletes()).toEqual(['/v1/article/12']) + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article')) + expect(toastText()).toContain('French version deleted') + expect(toastText()).toContain('"Couts de renovation" is gone. Other versions stay.') + }) + + it('leaves a dirty draft without asking and discards the stored copy', async () => { + await open() + await editBody('<p>Edits</p>') + await choose('Delete French version') + await press('Delete French version', dialogBox()!) + + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article')) + expect(document.body.querySelector('[role="alertdialog"]')).toBeNull() + expect(Object.keys(sessionStorage)).toEqual([]) + }) + + it('closes quietly and goes to the list when the version is already gone', async () => { + await open({}, { 'DELETE /v1/article/12': failWith(404, 'Article not found') }) + await choose('Delete French version') + await press('Delete French version', dialogBox()!) + + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article')) + expect(toastText()).not.toContain("Couldn't") + }) +}) + +describe('loc.l8.deleteLast: Delete the only version', () => { + it('names the language and deletes the article', async () => { + await open({ lang: 'en', translations: ['en'] }) + await choose('Delete article') + + expect(dialogText()).toContain('Delete the only version?') + expect(dialogText()).toContain( + 'English is the only version of this article. Deleting it deletes the article.', + ) + await press('Delete article', dialogBox()!) + + expect(deletes()).toEqual(['/v1/article/10']) + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article')) + expect(toastText()).toContain('Article deleted') + }) +}) + +describe('loc.l8.deleteStoryOk: Delete the whole article', () => { + it('names the readable versions in the site order', async () => { + await open() + await choose('Delete article') + + expect(dialogText()).toContain('Delete English, French, German and Arabic?') + expect(dialogText()).toContain( + "This deletes the article and every version you can read. It can't be undone.", + ) + }) + + it('deletes the other versions one after another and the open one last', async () => { + const events: string[] = [] + const slow = (id: number) => async () => { + events.push(`start ${id}`) + await new Promise((resolve) => setTimeout(resolve, 5)) + events.push(`end ${id}`) + return null + } + await open( + {}, + { + 'DELETE /v1/article/10': slow(10), + 'DELETE /v1/article/11': slow(11), + 'DELETE /v1/article/12': slow(12), + 'DELETE /v1/article/13': slow(13), + }, + ) + await choose('Delete article') + await press('Delete article', dialogBox()!) + await new Promise((resolve) => setTimeout(resolve, 60)) + await settle() + + expect(events).toEqual([ + 'start 10', + 'end 10', + 'start 11', + 'end 11', + 'start 13', + 'end 13', + 'start 12', + 'end 12', + ]) + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article')) + expect(toastText()).toContain('Article deleted') + }) + + it('never names or requests a version the owner cannot read', async () => { + await open({ translations: ['en', 'fr'] }) + await choose('Delete article') + + expect(dialogText()).toContain('Delete English and French?') + await press('Delete article', dialogBox()!) + expect(deletes()).toEqual(['/v1/article/10', '/v1/article/12']) + }) +}) + +describe('loc.l8.deleteStory: The whole-article delete is refused', () => { + it('shows the blocked dialog without a toast and switches to the version confirmation', async () => { + await open({}, { 'DELETE /v1/article/10': failWith(403, 'missing_permission') }) + await choose('Delete article') + await press('Delete article', dialogBox()!) + + expect(dialogText()).toContain("You can't delete this article") + expect(dialogText()).toContain( + 'Deleting an article needs delete permission on every version. You can still delete the version you are editing.', + ) + expect(toastText()).not.toContain('permission') + expect(deletes()).toEqual(['/v1/article/10']) + const buttons = Array.from(dialogBox()!.querySelectorAll('button')).map((button) => + button.textContent?.trim(), + ) + expect(buttons.slice(-2)).toEqual(['Close', 'Delete French version']) + + await press('Delete French version', dialogBox()!) + expect(dialogText()).toContain('Delete the French version?') + }) +}) + +describe('A whole-article delete stops half way', () => { + it.each([ + ['a 500', failWith(500, 'Internal server error')], + ['a 403', failWith(403, 'missing_permission')], + [ + 'a network failure', + () => { + throw new NetworkError() + }, + ], + ])('requests nothing more after %s and refetches the open version', async (_name, failure) => { + await open({}, { 'DELETE /v1/article/11': failure }) + const reads = callsTo('GET', '/v1/article/12').length + await choose('Delete article') + await press('Delete article', dialogBox()!) + + expect(deletes()).toEqual(['/v1/article/10', '/v1/article/11']) + expect(toastText()).toContain("Couldn't delete the whole article") + expect(toastText()).toContain( + 'Some versions were deleted. Open the language menu to see what is left.', + ) + expect(callsTo('GET', '/v1/article/12').length).toBe(reads + 1) + expect(router.currentRoute.value.path).toBe('/article/12/edit') + }) + + it('skips a version that is already gone and carries on to the open one', async () => { + await open({}, { 'DELETE /v1/article/11': failWith(404, 'Article not found') }) + await choose('Delete article') + await press('Delete article', dialogBox()!) + + expect(deletes()).toEqual([ + '/v1/article/10', + '/v1/article/11', + '/v1/article/13', + '/v1/article/12', + ]) + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article')) + }) + + it('shows Version unavailable when the open version is gone after the failure', async () => { + let reads = 0 + await open( + {}, + { + 'DELETE /v1/article/11': failWith(500, 'Internal server error'), + 'GET /v1/article/12': () => { + reads += 1 + if (reads > 1) { + throw new ApiError(404, undefined, 'Article not found') + } + return articleOf({ translations: [...ALL] }) + }, + }, + ) + await choose('Delete article') + await press('Delete article', dialogBox()!) + + expect(text()).toContain("This version isn't available") + }) +}) + +describe('A single-language site keeps the plain wording', () => { + it('reads Delete article and Delete this article? without naming a language', async () => { + await open({ + lang: 'en', + translations: ['en'], + languages: [{ lang: 'en', versions: 1, published: 1 }], + }) + await choose('Delete article') + + expect(dialogText()).toContain('Delete this article?') + expect(dialogText()).not.toContain('English') + expect(text()).not.toContain('Delete English version') + }) +}) diff --git a/web/src/views/__tests__/EditorHeaderActions.spec.ts b/web/src/views/__tests__/EditorHeaderActions.spec.ts index 34b0e31..b7dce3b 100644 --- a/web/src/views/__tests__/EditorHeaderActions.spec.ts +++ b/web/src/views/__tests__/EditorHeaderActions.spec.ts @@ -243,9 +243,9 @@ describe('loc.l5.ovf: Collapse order by width', () => { expect(menuItems().map((item) => item.textContent?.trim())).toEqual([ 'Unpublish French', `Edit slug${NOT_AVAILABLE}`, - `Move article${NOT_AVAILABLE}`, + 'Move article', 'Delete French version', - `Delete article${NOT_AVAILABLE}`, + 'Delete article', ]) }) }) @@ -259,22 +259,22 @@ describe('loc.perm.createNotAll: The overflow menu follows capabilities', () => expect(menuItems().map((item) => item.textContent?.trim())).toEqual([ `Edit slug${NOT_AVAILABLE}`, - `Move article${NOT_AVAILABLE}`, + 'Move article', 'Delete French version', - `Delete article${NOT_AVAILABLE}`, + 'Delete article', ]) }) - it('shows Edit slug, Move article and Delete article disabled with a reason until they are wired', async () => { + it('keeps only Edit slug disabled with a reason; Move article and both deletes are wired', async () => { await mountEditor() await openMoreActions() - for (const label of ['Edit slug', 'Move article', 'Delete article']) { - const item = menuItem(label)! - expect(item.getAttribute('aria-disabled')).toBe('true') - expect(item.textContent).toContain(NOT_AVAILABLE) + const slug = menuItem('Edit slug')! + expect(slug.getAttribute('aria-disabled')).toBe('true') + expect(slug.textContent).toContain(NOT_AVAILABLE) + for (const label of ['Move article', 'Delete French version', 'Delete article']) { + expect(menuItem(label)!.getAttribute('aria-disabled')).toBeNull() } - expect(menuItem('Delete French version')!.getAttribute('aria-disabled')).toBeNull() }) it('disables Delete article and Move with the reason when a sibling lacks delete', async () => { diff --git a/web/src/views/__tests__/EditorLanguageMenu.spec.ts b/web/src/views/__tests__/EditorLanguageMenu.spec.ts index f671a03..4030ef2 100644 --- a/web/src/views/__tests__/EditorLanguageMenu.spec.ts +++ b/web/src/views/__tests__/EditorLanguageMenu.spec.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import router from '@/router' import { initializeLocale } from '@/i18n' import { MOBILE_WIDTH } from '@/components/__tests__/viewport' import { settle, text, unmountRoute } from './localizationKit' @@ -159,12 +160,19 @@ describe('loc.l5b.withAdd, noAdd and roMenu: Add translation rows', () => { expect(text()).not.toContain('Add translation') }) - it('renders the Add translation row without doing anything when chosen', async () => { + it('opens the Add a translation dialog for the row and closes the menu', async () => { await mountEditor({ caps: FULL }) await openLanguageMenu() rowFor('German')!.click() await settle() + expect(router.currentRoute.value.fullPath).toBe( + '/article/12/edit?dialog=add-translation&lang=de', + ) + expect(document.body.querySelector('[role="menu"]')).toBeNull() + expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain( + 'Add a translation', + ) expect(titleInput().value).toBe('Couts de renovation') }) }) diff --git a/web/src/views/__tests__/EditorMetaSection.spec.ts b/web/src/views/__tests__/EditorMetaSection.spec.ts index 9b912d6..45a62ad 100644 --- a/web/src/views/__tests__/EditorMetaSection.spec.ts +++ b/web/src/views/__tests__/EditorMetaSection.spec.ts @@ -7,7 +7,6 @@ import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory' import { allMetaSections, bylines, - callsTo, click, createMetaBackend, failWith, @@ -59,6 +58,20 @@ function listboxTriggers() { ) } +function siteField() { + return document.body.querySelector<HTMLElement>( + 'button[aria-haspopup="dialog"][aria-label="Site"]', + )! +} + +function siteOptions() { + return Array.from( + document.body.querySelectorAll<HTMLElement>( + '[role="listbox"][aria-label="Site"] [role="option"]', + ), + ) +} + function bodyOpen() { return document.body.querySelector('[data-testid="meta-body"]') !== null } @@ -283,56 +296,47 @@ describe('Editor section: the Site select', () => { await mountMeta('editor', EDITOR, { backend }) const body = textOf(document.body.querySelector('[data-testid="meta-body"]')) - expect(listboxTriggers().map(textOf)[0]).toBe('Journal · journal.renew.example') + expect(textOf(siteField())).toBe('Journal · journal.renew.example') expect(body).toContain('Canonical host: journal.renew.example') expect(body.indexOf('Site')).toBeLessThan(body.indexOf('Authors')) }, ) - it('lists every site with its host, the default first', async () => { + it('opens the move panel from the Site field with the sites, the current one marked', async () => { await mountMeta('editor', EDITOR, { backend: backendWith('several', ['Inès Duarte'], 2), }) - listboxTriggers()[0]!.click() + siteField().click() await flushPromises() - const options = Array.from( - document.body.querySelectorAll<HTMLElement>('[role="option"]'), - ).map(textOf) - expect(options).toEqual([ - 'Renew renew.example', - 'Journal journal.renew.example', - 'Projects projects.renew.example', - 'Archive archive.renew.example', + expect(siteOptions().map((option) => textOf(option))).toEqual([ + 'Renew', + 'Journal', + 'Projects', + 'Archive', ]) + expect( + siteOptions() + .find((option) => textOf(option).startsWith('Journal'))! + .getAttribute('aria-selected'), + ).toBe('true') }) - it('treats a change as an edit: nothing is sent until Save, then one PATCH with only site_id', async () => { + it('stages nothing when a site is chosen: Save stays disabled and no site_id is sent', async () => { const backend = backendWith('several', ['Inès Duarte'], 1) await mountMeta('editor', EDITOR, { backend }) - expect(textOf(document.body)).toContain('Saved') expect(saveButtonEl().disabled).toBe(true) - listboxTriggers()[0]!.click() + siteField().click() await flushPromises() - Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')) - .find((item) => textOf(item).startsWith('Journal'))! + siteOptions() + .find((option) => textOf(option).startsWith('Journal'))! .click() await flushPromises() - expect(textOf(document.body)).toContain('Unsaved changes') - expect(saveButtonEl().disabled).toBe(false) + expect(saveButtonEl().disabled).toBe(true) + expect(textOf(document.body)).not.toContain('Unsaved changes') expect(patchCalls(backend)).toHaveLength(0) - - saveButtonEl().click() - await flushPromises() - - expect(patchCalls(backend)).toHaveLength(1) - expect(patchCalls(backend)[0]!.body).toEqual({ site_id: 2 }) - expect(textOf(document.body)).toContain('Saved') - expect(textOf(document.body.querySelector('[data-testid="meta-body"]'))).toContain( - 'Canonical host: journal.renew.example', - ) }) it('still loads the editor when the sites cannot be loaded, with no select and no notice', async () => { @@ -349,26 +353,6 @@ describe('Editor section: the Site select', () => { }) describe('Editor section: a site or author that no longer exists', () => { - it('keeps the changes, shows Save failed, an info toast, and reloads the sites', async () => { - const backend = backendWith('several', ['Inès Duarte'], 1) - backend.responders['PATCH /v1/article/12'] = failWith(404, 'site_not_found') - await mountMeta('editor', EDITOR, { backend }) - listboxTriggers()[0]!.click() - await flushPromises() - Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')) - .find((item) => textOf(item).startsWith('Journal'))! - .click() - await flushPromises() - - saveButtonEl().click() - await flushPromises() - - expect(textOf(document.body)).toContain('Save failed') - expect(toasts()).toContain('That site no longer exists') - expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(2) - expect(listboxTriggers()[0]!.textContent).toContain('Journal') - }) - it('keeps the added author and shows the author toast on author_not_found', async () => { const backend = backendWith('one', ['Inès Duarte']) backend.responders['PATCH /v1/article/12'] = failWith(404, 'author_not_found') @@ -396,7 +380,7 @@ describe('Editor section: the existing save behaviour treats the change like any listboxTriggers()[0]!.click() await flushPromises() Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')) - .find((item) => textOf(item).startsWith('Journal'))! + .find((item) => textOf(item).includes('Camille Roche'))! .click() await flushPromises() } @@ -410,10 +394,10 @@ describe('Editor section: the existing save behaviour treats the change like any await flushPromises() expect(patchCalls(backend)).toHaveLength(1) - expect(patchCalls(backend)[0]!.body).toEqual({ site_id: 2 }) + expect(patchCalls(backend)[0]!.body).toMatchObject({ author_ids: expect.any(Array) }) }) - it('asks before leaving with an unsaved site change', async () => { + it('asks before leaving with an unsaved author change', async () => { await changeSite(backendWith('several', ['Inès Duarte'])) const leaving = router.push('/article') @@ -447,7 +431,7 @@ describe('Editor section: the existing save behaviour treats the change like any } }) - it('keeps the chosen site in sessionStorage when the session expires and restores it', async () => { + it('keeps the added author in sessionStorage when the session expires and restores it', async () => { const backend = backendWith('several', ['Inès Duarte']) backend.responders['PATCH /v1/article/12'] = failWith(401) await changeSite(backend) @@ -455,7 +439,9 @@ describe('Editor section: the existing save behaviour treats the change like any await flushPromises() const stored = JSON.parse(sessionStorage.getItem('renewcms:article-draft:1:12')!) - expect(stored.meta).toMatchObject({ site_id: 2 }) + expect(stored.meta.authors.map((author: { name: string }) => author.name)).toContain( + 'Camille Roche', + ) expect(stored.body).toBeUndefined() await unmountMeta() @@ -463,6 +449,6 @@ describe('Editor section: the existing save behaviour treats the change like any backend: backendWith('several', ['Inès Duarte']), }) expect(textOf(document.body)).toContain('Unsaved changes') - expect(listboxTriggers()[0]!.textContent).toContain('Journal') + expect(textOf(metaSection())).toContain('Camille Roche') }) }) diff --git a/web/src/views/__tests__/EditorMove.spec.ts b/web/src/views/__tests__/EditorMove.spec.ts new file mode 100644 index 0000000..fd31319 --- /dev/null +++ b/web/src/views/__tests__/EditorMove.spec.ts @@ -0,0 +1,522 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { initializeLocale } from '@/i18n' +import { ApiError } from '@/lib/apiError' +import { + callsTo, + deferred, + dialogBox, + dialogText, + press, + settle, + text, + toastText, + unmountRoute, +} from './localizationKit' +import { + FULL, + READ, + REVISIONS, + articleOf, + buttonNamed, + editBody, + mountEditor, + openMoreActions, + type EditorFixture, +} from './editorKit' +import { languagesBody } from './localizationData' + +vi.mock('@/composables/useApi', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/composables/useApi')>() + return { ...actual, useApi: vi.fn() } +}) + +const ALL = ['en', 'fr', 'de', 'ar'] as const + +function site(id: number, key: string, name: string, isDefault = false) { + return { + id, + key, + name, + base_url: `https://${key}.example.com`, + default_social_image_id: null, + default_robots: 'index', + locale: 'en', + is_default: isDefault, + article_count: 4, + } +} + +const SITES = [ + site(1, 'journal', 'Journal', true), + site(2, 'atelier', 'Atelier'), + site(4, 'archive', 'Archive'), + site(3, 'notes', 'Notes'), +] + +const MOVED = { story_id: 5, site: 'atelier', site_id: 2, article_ids: [10, 12, 11, 13] } + +beforeEach(() => { + initializeLocale() + sessionStorage.clear() +}) + +afterEach(unmountRoute) + +function open(fixture: EditorFixture = {}, extra: Record<string, unknown> = {}) { + return mountEditor({ + translations: [...ALL], + siblingCaps: { ar: READ }, + ...fixture, + extra: { + 'GET /v1/site/': SITES, + 'GET /v1/site/notes/languages': languagesBody('n', 'en', [ + { lang: 'en', versions: 1, published: 1 }, + ]), + 'POST /v1/story/5/move': MOVED, + ...extra, + } as never, + }) +} + +function listbox() { + return document.body.querySelector<HTMLElement>('[role="listbox"][aria-label="Site"]') +} + +function options() { + return Array.from(listbox()?.querySelectorAll<HTMLElement>('[role="option"]') ?? []) +} + +function siteField() { + return document.body.querySelector<HTMLElement>( + 'button[aria-haspopup="dialog"][aria-label="Site"]', + )! +} + +async function openPanel() { + siteField().click() + await settle() +} + +async function choosePanelSite(name: string) { + options() + .find((option) => option.textContent?.includes(name))! + .click() + await settle() +} + +async function confirmMoveTo(name: string) { + await openPanel() + await choosePanelSite(name) + await press('Move article…') +} + +function moveCalls() { + return callsTo('POST', '/v1/story/5/move') +} + +describe('loc.l11b.moveSelect: The panel opens from the Site field or More actions', () => { + it('lists the sites in a listbox named Site with the note of readable versions', async () => { + await open() + await openPanel() + + expect(options().map((option) => option.textContent?.trim())).toEqual([ + 'Journal', + 'Atelier', + 'Archive', + 'Notes', + ]) + expect(options()[0]!.getAttribute('aria-selected')).toBe('true') + const note = dialogText() + expect(note).toContain('Moves all language versions of this article.') + for (const line of [ + 'English · Published', + 'French · Draft', + 'German · Draft', + 'Arabic · Published · Read only', + ]) { + expect(note).toContain(line) + } + }) + + it('opens from the More actions item too', async () => { + await open({ siblingCaps: { ar: FULL } }) + await openMoreActions() + Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')) + .find((item) => item.textContent?.trim().startsWith('Move article'))! + .click() + await settle() + + expect(listbox()).not.toBeNull() + }) + + it('keeps Move article… disabled until another site is chosen and stages nothing', async () => { + await open() + await openPanel() + const primary = () => + Array.from(dialogBox()!.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Move article…', + )! + + expect(primary().hasAttribute('disabled')).toBe(true) + await choosePanelSite('Atelier') + expect(primary().hasAttribute('disabled')).toBe(false) + await choosePanelSite('Journal') + expect(primary().hasAttribute('disabled')).toBe(true) + expect(text()).not.toContain('Unsaved changes') + expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(0) + }) + + it('lists no sentence or footnote about versions the caller cannot read', async () => { + await open({ translations: ['en', 'fr', 'de'] }) + await openPanel() + + expect(dialogText()).toContain('English · Published') + expect(dialogText()).not.toContain('Arabic') + expect(dialogText()).not.toMatch(/can't read|hidden|unreadable/i) + }) + + it('moves focus into the listbox, arrows move and the panel closes on Escape', async () => { + await open() + await openPanel() + + expect(document.activeElement).toBe(options()[0]) + document.activeElement!.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }), + ) + await settle() + expect(document.activeElement).toBe(options()[1]) + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + bubbles: true, + cancelable: true, + }), + ) + await settle() + expect(listbox()).toBeNull() + }) +}) + +describe('loc.l11b.moveConfirm, moveBusy and moveDone', () => { + it('confirms with the versions and the addresses alert', async () => { + await open() + await openPanel() + await choosePanelSite('Atelier') + await press('Move article…') + + expect(dialogText()).toContain('Move to Atelier?') + expect(dialogText()).toContain('This moves every version together. Nothing is split.') + expect(dialogText()).toContain('English · Published') + expect(dialogText()).toContain('Addresses and links') + expect(dialogText()).toContain( + 'Each version keeps its address. Links from the old site to it will stop working. RenewCMS does not create redirects.', + ) + }) + + it('sends the site key and locks the dialog while moving', async () => { + const gate = deferred<unknown>() + await open({}, { 'POST /v1/story/5/move': () => gate.promise }) + await confirmMoveTo('Atelier') + await press('Move article', dialogBox()!) + + expect(moveCalls()[0]!.body).toEqual({ site: 'atelier' }) + expect(dialogText()).toContain('Moving to Atelier…') + expect(dialogText()).toContain('All 4 versions move together. Keep this page open.') + expect(dialogText()).toContain('This takes a few seconds.') + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + bubbles: true, + cancelable: true, + }), + ) + await settle() + expect(dialogBox()).not.toBeNull() + gate.resolve(MOVED) + await settle() + }) + + it('toasts, announces, refetches silently, keeps unsaved edits and saves with the new revision', async () => { + let reads = 0 + await open( + {}, + { + 'GET /v1/article/12': () => { + reads += 1 + return reads === 1 + ? articleOf({ translations: [...ALL] }) + : { ...articleOf({ translations: [...ALL] }), site_id: 2, revision: 8 } + }, + 'PATCH /v1/article/12': { ...articleOf({}), revision: 9 }, + }, + ) + await editBody('<p>Edits</p>') + await confirmMoveTo('Atelier') + await press('Move article', dialogBox()!) + + expect(dialogBox()).toBeNull() + expect(toastText()).toContain('Moved to Atelier') + expect(toastText()).toContain('All 4 versions moved together.') + expect(document.body.querySelector('[data-testid="live-announcement"]')?.textContent).toBe( + 'Moved to Atelier', + ) + expect(reads).toBe(2) + expect(text()).toContain('Unsaved changes') + await press('Save') + expect(callsTo('PATCH', '/v1/article/12')[0]!.body).toEqual({ + body: '<p>Edits</p>', + if_revision: 8, + }) + expect(REVISIONS.fr).toBe(3) + }) +}) + +describe('The silent refetch after a move', () => { + const OUT_OF_DATE = 'This page may be out of date. Reload it to see the latest.' + + function announcement() { + return document.body.querySelector('[data-testid="live-announcement"]')?.textContent + } + + it('retries once and keeps quiet when the second read succeeds', async () => { + let reads = 0 + await open( + {}, + { + 'GET /v1/article/12': () => { + reads += 1 + if (reads === 2) { + throw new ApiError(500, undefined, 'Internal server error') + } + return articleOf({ translations: [...ALL] }) + }, + }, + ) + await confirmMoveTo('Atelier') + await press('Move article', dialogBox()!) + await settle() + + expect(reads).toBe(3) + expect(announcement()).toBe('Moved to Atelier') + }) + + it('announces politely that the page may be out of date after two failures and keeps edits', async () => { + let reads = 0 + await open( + {}, + { + 'GET /v1/article/12': () => { + reads += 1 + if (reads > 1) { + throw new ApiError(500, undefined, 'Internal server error') + } + return articleOf({ translations: [...ALL] }) + }, + }, + ) + await editBody('<p>Edits</p>') + await confirmMoveTo('Atelier') + await press('Move article', dialogBox()!) + await settle() + + expect(reads).toBe(3) + expect(announcement()).toBe(OUT_OF_DATE) + expect(text()).toContain('Unsaved changes') + }) + + it('keeps what changed while the refetch was in flight', async () => { + const pending = deferred<ReturnType<typeof articleOf>>() + let reads = 0 + await open( + { translations: [...ALL] }, + { + 'GET /v1/article/12': () => { + reads += 1 + return reads === 1 ? articleOf({ translations: [...ALL] }) : pending.promise + }, + 'PATCH /v1/article/12/publish': articleOf({ + translations: [...ALL], + online: { fr: true }, + }), + }, + ) + await confirmMoveTo('Atelier') + await press('Move article', dialogBox()!) + await press('Publish French') + await press('Publish French', dialogBox()!) + pending.resolve(articleOf({ translations: [...ALL] })) + await settle() + + expect(buttonNamed('Unpublish French')).toBeTruthy() + }) +}) + +describe('Refused moves keep the dialog open and move nothing', () => { + function primaryDisabled() { + return Array.from(dialogBox()!.querySelectorAll('button')) + .find((button) => button.textContent?.trim() === 'Move article')! + .hasAttribute('disabled') + } + + it('loc.l11b.move: names the missing readable languages from the destination', async () => { + await open( + {}, + { + 'POST /v1/story/5/move': () => { + throw new ApiError(422, undefined, 'language_not_enabled', { + missingLanguages: ['fr', 'de', 'ar'], + }) + }, + }, + ) + await confirmMoveTo('Notes') + await press('Move article', dialogBox()!) + + expect(callsTo('GET', '/v1/site/notes/languages')).toHaveLength(1) + expect(dialogText()).toContain("Notes doesn't enable every language in this article") + expect(dialogText()).toContain( + 'Notes enables English only. Enable French, German and Arabic on Notes first. Languages disabled on Journal count too. Nothing was moved.', + ) + expect(primaryDisabled()).toBe(true) + }) + + it('never names a language the owner cannot read', async () => { + await open( + { translations: ['en', 'fr', 'de'] }, + { + 'POST /v1/story/5/move': () => { + throw new ApiError(422, undefined, 'language_not_enabled', { + missingLanguages: ['fr', 'de', 'ar'], + }) + }, + }, + ) + await confirmMoveTo('Notes') + await press('Move article', dialogBox()!) + + expect(dialogText()).toContain('Enable French and German on Notes first.') + expect(dialogText()).not.toContain('Arabic') + }) + + it('loc.l11b.moveSlug: names the readable conflicting version with its address and no Edit slug', async () => { + await open( + {}, + { + 'POST /v1/story/5/move': () => { + throw new ApiError(409, undefined, 'slug_conflict', { + conflictingArticles: [12], + }) + }, + 'GET /v1/article/12': { ...articleOf({ translations: [...ALL] }), slug: 'couts' }, + }, + ) + await confirmMoveTo('Atelier') + await press('Move article', dialogBox()!) + + expect(dialogText()).toContain('An address is already used on Atelier') + expect(dialogText()).toContain( + 'The French address "couts" is already used by another French article on Atelier. Change it before moving. Nothing was moved.', + ) + expect(dialogText()).not.toContain('Edit slug') + expect(primaryDisabled()).toBe(true) + }) + + it('writes one sentence per conflicting readable version and skips hidden ones', async () => { + await open( + {}, + { + 'POST /v1/story/5/move': () => { + throw new ApiError(409, undefined, 'slug_conflict', { + conflictingArticles: [12, 11, 99], + }) + }, + 'GET /v1/article/12': { ...articleOf({ translations: [...ALL] }), slug: 'couts' }, + 'GET /v1/article/11': { + ...articleOf({ translations: [...ALL] }, 'de'), + slug: 'kosten', + }, + }, + ) + await confirmMoveTo('Atelier') + await press('Move article', dialogBox()!) + + expect(dialogText()).toContain('The French address "couts"') + expect(dialogText()).toContain('The German address "kosten"') + expect(callsTo('GET', '/v1/article/99')).toHaveLength(0) + }) + + it('loc.l11b.movePerm: a neutral dialog with Close and no global toast on a 403', async () => { + await open( + {}, + { + 'POST /v1/story/5/move': () => { + throw new ApiError(403, undefined, 'missing_permission', { + code: 'missing_permission', + }) + }, + }, + ) + await confirmMoveTo('Atelier') + await press('Move article', dialogBox()!) + + expect(dialogText()).toContain("You can't move this article") + expect(dialogText()).toContain('Moving needs edit permission on every version') + expect(dialogText()).toContain('Ask an owner to move it. Nothing was changed.') + expect(toastText()).not.toContain('permission') + await press('Close', dialogBox()!) + expect(dialogBox()).toBeNull() + }) + + it.each([ + ['a 500', () => new ApiError(500)], + ['a network failure', () => new TypeError('Failed to fetch')], + ])("shows Couldn't move the article after %s", async (_name, make) => { + await open( + {}, + { + 'POST /v1/story/5/move': () => { + throw make() + }, + }, + ) + await confirmMoveTo('Atelier') + await press('Move article', dialogBox()!) + + expect(dialogText()).toContain("Couldn't move the article") + expect(dialogText()).toContain('Nothing was moved. Try again.') + }) + + it('refetches the sites after a 404 site_not_found', async () => { + await open( + {}, + { + 'POST /v1/story/5/move': () => { + throw new ApiError(404, undefined, 'site_not_found') + }, + }, + ) + await confirmMoveTo('Atelier') + await press('Move article', dialogBox()!) + + expect(dialogText()).toContain("Couldn't move the article") + expect(callsTo('GET', '/v1/site/')).toHaveLength(2) + }) + + it('closes the dialog and shows Version unavailable after a 404 Article not found', async () => { + await open( + {}, + { + 'POST /v1/story/5/move': () => { + throw new ApiError(404, undefined, 'Article not found') + }, + }, + ) + await confirmMoveTo('Atelier') + await press('Move article', dialogBox()!) + + expect(dialogBox()).toBeNull() + expect(text()).toContain("This version isn't available") + }) +}) diff --git a/web/src/views/__tests__/EditorRevisions.spec.ts b/web/src/views/__tests__/EditorRevisions.spec.ts new file mode 100644 index 0000000..7d29d5d --- /dev/null +++ b/web/src/views/__tests__/EditorRevisions.spec.ts @@ -0,0 +1,543 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import router from '@/router' +import { initializeLocale } from '@/i18n' +import { NetworkError } from '@/composables/useApi' +import { + WRITE_FAILS, + callsTo, + dialogBox, + dialogText, + failWith, + kit, + press, + settle, + text, + toastText, + typeInto, + unmountRoute, +} from './localizationKit' +import { + REVISIONS, + articleOf, + buttonNamed, + editBody, + mountEditor, + openLanguageMenu, + rowFor, + titleInput, +} from './editorKit' + +vi.mock('@/composables/useApi', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/composables/useApi')>() + return { ...actual, useApi: vi.fn() } +}) + +const KEY = 'renewcms:article-draft:7:12' +const THEIRS_AT = '2026-10-14T14:32:00Z' +const AGAIN_AT = '2026-10-14T14:41:00Z' +const THREE = ['en', 'fr', 'de'] as const + +beforeEach(() => { + initializeLocale() + sessionStorage.clear() +}) + +afterEach(async () => { + vi.unstubAllGlobals() + await unmountRoute() +}) + +function patchBodies() { + return callsTo('PATCH', '/v1/article/12').map((call) => call.body) +} + +function readBlob(blob: Blob) { + return new Promise<string>((resolve) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result)) + reader.readAsText(blob) + }) +} + +const conflict = (revision = 4) => failWith(409, 'revision_conflict', { currentRevision: revision }) + +function theirs(updatedAt = THEIRS_AT, revision = 4) { + return { ...articleOf({}), title: 'Couts par un autre', revision, updated_at: updatedAt } +} + +function conflictRoutes(overrides: Record<string, unknown> = {}) { + let reads = 0 + return { + 'PATCH /v1/article/12': conflict(), + 'GET /v1/article/12': () => { + reads += 1 + return reads === 1 ? articleOf({}) : theirs() + }, + ...overrides, + } as never +} + +async function openConflict(overrides: Record<string, unknown> = {}) { + await mountEditor({ extra: conflictRoutes(overrides) }) + await editBody('<p>Mes changements</p>') + await press('Save') +} + +describe('Every save of the open version carries if_revision', () => { + it('sends the loaded revision with the changed fields and takes the revision of the answer', async () => { + await mountEditor({ + extra: { 'PATCH /v1/article/12': { ...articleOf({}), revision: 4 } }, + }) + await editBody('<p>One</p>') + await press('Save') + await editBody('<p>Two</p>') + await press('Save') + + expect(patchBodies()).toEqual([ + { body: '<p>One</p>', if_revision: REVISIONS.fr }, + { body: '<p>Two</p>', if_revision: 4 }, + ]) + }) + + it('sends it when saving before publishing', async () => { + await mountEditor({ + translations: [...THREE], + extra: { + 'PATCH /v1/article/12': articleOf({ translations: [...THREE] }), + 'PATCH /v1/article/12/publish': articleOf({ + translations: [...THREE], + online: { fr: true }, + }), + } as never, + }) + await editBody('<p>One</p>') + await press('Save and publish French') + await press('Save and publish French', dialogBox()!) + + expect(patchBodies()[0]).toMatchObject({ if_revision: 3 }) + }) + + it('sends it when saving before opening English', async () => { + await mountEditor({ + translations: [...THREE], + extra: { + 'PATCH /v1/article/12': articleOf({ translations: [...THREE] }), + 'GET /v1/article/10': articleOf({ lang: 'fr', translations: [...THREE] }, 'en'), + } as never, + }) + await editBody('<p>One</p>') + await openLanguageMenu() + rowFor('English')!.click() + await settle() + await press('Save and open English') + + expect(patchBodies()[0]).toMatchObject({ if_revision: 3 }) + }) + + it('sends the same revision again on Try saving again', async () => { + let failures = 1 + await mountEditor({ + extra: { + 'PATCH /v1/article/12': () => { + if (failures-- > 0) { + throw new NetworkError() + } + return articleOf({}) + }, + } as never, + }) + await editBody('<p>One</p>') + await press('Save') + await press('Try saving again') + + expect(patchBodies()).toEqual([ + { body: '<p>One</p>', if_revision: 3 }, + { body: '<p>One</p>', if_revision: 3 }, + ]) + }) + + it('sends it on the title helper Try again', async () => { + await mountEditor({ extra: { 'PATCH /v1/article/12': WRITE_FAILS } }) + await typeInto(titleInput(), 'Couts reels') + await press('Save') + await press('Try again') + + expect( + patchBodies().every((body) => (body as { if_revision: number }).if_revision === 3), + ).toBe(true) + }) + + it('publishes and unpublishes with no body and leaves the base revision', async () => { + await mountEditor({ + translations: [...THREE], + extra: { + 'PATCH /v1/article/12/publish': articleOf({ + translations: [...THREE], + online: { fr: true }, + }), + 'PATCH /v1/article/12': { ...articleOf({}), revision: 9 }, + } as never, + }) + await press('Publish French') + await press('Publish French', dialogBox()!) + await editBody('<p>One</p>') + await press('Save') + + expect(callsTo('PATCH', '/v1/article/12/publish')[0]!.body).toBeUndefined() + expect(patchBodies()[0]).toMatchObject({ if_revision: 3 }) + }) +}) + +describe('loc.l11.conflictRestored: A restored draft keeps the revision it was written against', () => { + it('sends revision 3 while the server is at 5 and shows the Restored notice in the dialog', async () => { + sessionStorage.setItem( + KEY, + JSON.stringify({ + body: '<p>Stored body</p>', + revision: 3, + mirrored_at: '2026-10-14T14:18:00Z', + }), + ) + await mountEditor({ + extra: conflictRoutes({ + 'GET /v1/article/12': { ...articleOf({}), revision: 5 }, + }), + }) + await press('Save') + + expect(patchBodies()[0]).toMatchObject({ if_revision: 3 }) + expect(dialogText()).toContain('Restored unsaved changes from 14:18') + expect(dialogText()).toContain( + 'Your session ended. These changes were kept in this browser.', + ) + }) + + it('uses the revision loaded now for a stored draft that has none', async () => { + sessionStorage.setItem( + KEY, + JSON.stringify({ body: '<p>Stored body</p>', mirrored_at: '2026-10-14T14:18:00Z' }), + ) + await mountEditor({ extra: { 'PATCH /v1/article/12': articleOf({}) } }) + await press('Save') + + expect(patchBodies()[0]).toMatchObject({ if_revision: 3 }) + }) +}) + +describe('loc.conflict: A stale save opens the dialog', () => { + it('reads their version for the time and offers the four choices in order', async () => { + await openConflict() + + expect(callsTo('GET', '/v1/article/12')).toHaveLength(2) + expect(dialogText()).toContain('Someone else saved this version') + expect(dialogText()).toContain( + "Someone else saved this version at 14:32. Your changes aren't saved. They stay here until you choose.", + ) + const labels = Array.from(dialogBox()!.querySelectorAll('button')) + .map((button) => button.textContent?.trim()) + .filter((label) => label && label !== 'Close') + expect(labels).toEqual([ + 'Keep my changes', + 'Download my version', + 'Reload theirs', + 'Save over theirs', + ]) + expect(dialogText()).not.toMatch(/revision|\b4\b/i) + expect(text()).toContain('Unsaved changes') + }) + + it('starts focus on Keep my changes', async () => { + await openConflict() + + expect(document.activeElement?.textContent?.trim()).toBe('Keep my changes') + }) + + it('fills only Keep my changes', async () => { + await openConflict() + + const keep = Array.from(dialogBox()!.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Keep my changes', + )! + const others = ['Download my version', 'Reload theirs', 'Save over theirs'].map( + (label) => + Array.from(dialogBox()!.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label, + )!, + ) + expect(others.every((button) => button.className === others[0]!.className)).toBe(true) + expect(keep.className).not.toBe(others[0]!.className) + }) + + it('shows all four options on a phone', async () => { + await mountEditor({ width: 390, extra: conflictRoutes() }) + await editBody('<p>Mes changements</p>') + await press('Save') + + expect(dialogText()).toContain('Save over theirs') + expect(dialogBox()!.className).toMatch(/max-sm:/) + }) +}) + +describe('Conflicts during a chained action stop the chain', () => { + it('never sends publish after a conflict on Save and publish', async () => { + await mountEditor({ + translations: [...THREE], + extra: { + 'PATCH /v1/article/12': conflict(), + 'GET /v1/article/12': () => articleOf({ translations: [...THREE] }), + } as never, + }) + await editBody('<p>One</p>') + await press('Save and publish French') + await press('Save and publish French', dialogBox()!) + + expect(dialogText()).toContain('Someone else saved this version') + expect(patchBodies()).toHaveLength(1) + expect(callsTo('PATCH', '/v1/article/12/publish')).toHaveLength(0) + }) + + it('never requests English after a conflict on Save and open English', async () => { + await mountEditor({ + translations: [...THREE], + extra: { + 'PATCH /v1/article/12': conflict(), + 'GET /v1/article/12': () => articleOf({ translations: [...THREE] }), + 'GET /v1/article/10': articleOf({ lang: 'fr' }, 'en'), + } as never, + }) + await editBody('<p>One</p>') + await openLanguageMenu() + rowFor('English')!.click() + await settle() + await press('Save and open English') + + expect(dialogText()).toContain('Someone else saved this version') + expect(patchBodies()).toHaveLength(1) + expect(callsTo('GET', '/v1/article/10')).toHaveLength(0) + }) +}) + +describe('The conflict reload fails', () => { + it.each([ + ['a 500', failWith(500, 'Internal server error')], + [ + 'a network failure', + () => { + throw new NetworkError() + }, + ], + ])('shows Save failed with the edits kept after %s', async (_name, failure) => { + let reads = 0 + await mountEditor({ + extra: { + 'PATCH /v1/article/12': conflict(), + 'GET /v1/article/12': (call: unknown) => { + reads += 1 + return reads === 1 ? articleOf({}) : (failure as (c: unknown) => unknown)(call) + }, + } as never, + }) + await editBody('<p>Mes changements</p>') + await press('Save') + + expect(dialogBox()).toBeNull() + expect(text()).toContain('Save failed') + await press('Try saving again') + expect(patchBodies()).toHaveLength(2) + expect(patchBodies()[1]).toEqual(patchBodies()[0]) + }) +}) + +describe('Keep my changes', () => { + it('closes the dialog, sends nothing and keeps the edits; the next Save conflicts again', async () => { + await openConflict() + await press('Keep my changes') + + expect(dialogBox()).toBeNull() + expect(patchBodies()).toHaveLength(1) + expect(text()).toContain('Unsaved changes') + await press('Save') + expect(patchBodies()).toHaveLength(2) + expect(dialogText()).toContain('Someone else saved this version') + }) + + it('treats Escape like Keep my changes and returns focus to the title', async () => { + await openConflict() + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + bubbles: true, + cancelable: true, + }), + ) + await settle() + + expect(dialogBox()).toBeNull() + expect(document.activeElement).toBe(titleInput()) + }) +}) + +describe('loc.l11.download: Download my version', () => { + it('downloads the title, a blank line and the body, with a toast and no request', async () => { + const blobs: Blob[] = [] + URL.createObjectURL = vi.fn((blob: Blob | MediaSource) => { + blobs.push(blob as Blob) + return 'blob:conflict' + }) + URL.revokeObjectURL = vi.fn() + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) + await openConflict() + const requests = kit.calls.length + await press('Download my version') + + expect(await readBlob(blobs[0]!)).toBe('Couts de renovation\n\nMes changements\n') + expect(kit.calls).toHaveLength(requests) + expect(URL.revokeObjectURL).not.toHaveBeenCalled() + expect(toastText()).toContain('Download started') + expect(toastText()).toContain( + 'Your French title and body are saved as a text file. Your changes are still in the editor.', + ) + expect(document.body.querySelector('[data-testid="live-announcement"]')?.textContent).toBe( + 'Download started', + ) + expect(dialogBox()).not.toBeNull() + }) +}) + +describe('loc.l11.reloadConfirm: Reload theirs asks first', () => { + it('switches to the confirmation and discards the edits when confirmed', async () => { + await openConflict() + sessionStorage.setItem(KEY, JSON.stringify({ body: '<p>x</p>', mirrored_at: THEIRS_AT })) + await press('Reload theirs') + + expect(dialogText()).toContain('Reload theirs?') + expect(dialogText()).toContain('This discards your unsaved changes.') + await press('Reload and discard my changes') + + expect(dialogBox()).toBeNull() + expect(titleInput().value).toBe('Couts par un autre') + expect(text()).not.toContain('Unsaved changes') + expect(sessionStorage.getItem(KEY)).toBeNull() + await editBody('<p>Next</p>') + await press('Save') + expect(patchBodies().at(-1)).toMatchObject({ if_revision: 4 }) + }) + + it('goes back to editing with Keep my changes from the confirmation', async () => { + await openConflict() + await press('Reload theirs') + await press('Keep my changes') + + expect(dialogBox()).toBeNull() + expect(text()).toContain('Unsaved changes') + }) +}) + +describe('loc.l11.conflict2 and Save over theirs outcomes', () => { + function routesWith(second: unknown) { + let patches = 0 + let reads = 0 + return { + 'PATCH /v1/article/12': (call: unknown) => { + patches += 1 + if (patches === 1) { + return conflict()(call as never) + } + return typeof second === 'function' + ? (second as (c: unknown) => unknown)(call) + : second + }, + 'GET /v1/article/12': () => { + reads += 1 + if (reads === 1) { + return articleOf({}) + } + return reads === 2 ? theirs() : theirs(AGAIN_AT, 6) + }, + } as never + } + + it('sends if_revision 4, closes the dialog and takes the new revision', async () => { + await mountEditor({ extra: routesWith({ ...articleOf({}), revision: 5 }) }) + await editBody('<p>Mes changements</p>') + await press('Save') + const reads = callsTo('GET', '/v1/article/12').length + await press('Save over theirs') + + expect(patchBodies()[1]).toMatchObject({ body: '<p>Mes changements</p>', if_revision: 4 }) + expect(callsTo('GET', '/v1/article/12')).toHaveLength(reads) + expect(dialogBox()).toBeNull() + expect(text()).toContain('Saved') + expect(callsTo('GET', '/v1/article/10')).toHaveLength(0) + }) + + it('reads again on a second conflict, keeps the dialog and shows the newer time', async () => { + await mountEditor({ + extra: routesWith(failWith(409, 'revision_conflict', { currentRevision: 6 })), + }) + await editBody('<p>Mes changements</p>') + await press('Save') + await press('Save over theirs') + + expect(dialogText()).toContain("Couldn't replace theirs") + expect(dialogText()).toContain( + 'Someone else saved this version again at 14:41, before your save went through. Nothing was overwritten. Your changes are still here.', + ) + await press('Save over theirs') + expect(patchBodies()[2]).toMatchObject({ if_revision: 6 }) + }) + + it.each([ + ['a 500', failWith(500, 'Internal server error')], + [ + 'a network failure', + () => { + throw new NetworkError() + }, + ], + ])('keeps the dialog with a retryable alert after %s', async (_name, failure) => { + await mountEditor({ extra: routesWith(failure) }) + await editBody('<p>Mes changements</p>') + await press('Save') + await press('Save over theirs') + + expect(dialogText()).toContain("Couldn't replace theirs") + expect(dialogText()).toContain( + 'Nothing was overwritten. Your changes are still here. Try again.', + ) + expect(buttonNamed('Save over theirs')!.hasAttribute('disabled')).toBe(false) + }) + + it('mirrors the draft and goes to login on a 401', async () => { + await mountEditor({ extra: routesWith(failWith(401)) }) + await editBody('<p>Mes changements</p>') + await press('Save') + await press('Save over theirs') + + expect(JSON.parse(sessionStorage.getItem(KEY)!)).toMatchObject({ + body: '<p>Mes changements</p>', + }) + }) + + it('closes the dialog on a 403 and shows Save failed', async () => { + await mountEditor({ extra: routesWith(failWith(403, 'missing_permission')) }) + await editBody('<p>Mes changements</p>') + await press('Save') + await press('Save over theirs') + + expect(dialogBox()).toBeNull() + expect(text()).toContain('Save failed') + }) + + it('closes the dialog and shows Version unavailable on a 404', async () => { + await mountEditor({ extra: routesWith(failWith(404, 'Article not found')) }) + await editBody('<p>Mes changements</p>') + await press('Save') + await press('Save over theirs') + + expect(dialogBox()).toBeNull() + expect(text()).toContain("This version isn't available") + expect(router.currentRoute.value.path).toBe('/article/12/edit') + }) +}) diff --git a/web/src/views/__tests__/EditorTitle.spec.ts b/web/src/views/__tests__/EditorTitle.spec.ts index f4e000e..81c31cc 100644 --- a/web/src/views/__tests__/EditorTitle.spec.ts +++ b/web/src/views/__tests__/EditorTitle.spec.ts @@ -174,7 +174,11 @@ describe('loc.l5.titleSaving: Saving the title sends it with the other edits', ( const calls = callsTo('PATCH', '/v1/article/12') expect(calls).toHaveLength(1) - expect(calls[0]!.body).toEqual({ title: 'Couts reels', body: '<p>New body</p>' }) + expect(calls[0]!.body).toEqual({ + title: 'Couts reels', + body: '<p>New body</p>', + if_revision: 3, + }) }) it('shows a spinner and "Saving title…" while in flight', async () => { diff --git a/web/src/views/__tests__/HomeView.spec.ts b/web/src/views/__tests__/HomeView.spec.ts index fe17e14..5e1aafa 100644 --- a/web/src/views/__tests__/HomeView.spec.ts +++ b/web/src/views/__tests__/HomeView.spec.ts @@ -247,7 +247,7 @@ describe('A successful create hands off to the editor with a toast', () => { await flushPromises() const confirmButton = Array.from(document.body.querySelectorAll('button')).find( - (button) => button.textContent?.trim() === 'Create draft', + (button) => button.textContent?.trim() === 'Create article', ) confirmButton?.click() await flushPromises() diff --git a/web/src/views/__tests__/editorKit.ts b/web/src/views/__tests__/editorKit.ts index f3ae685..e5659fe 100644 --- a/web/src/views/__tests__/editorKit.ts +++ b/web/src/views/__tests__/editorKit.ts @@ -20,6 +20,7 @@ export const TITLES: Record<Lang, string> = { de: 'Renovierungskosten', } const ONLINE: Record<Lang, boolean> = { en: true, fr: false, ar: true, de: false } +export const REVISIONS: Record<Lang, number> = { en: 7, fr: 3, ar: 5, de: 2 } const UPDATED_AT = '2026-09-26T11:02:00Z' const TRANSLATION_ORDER: Lang[] = ['en', 'ar', 'fr'] @@ -87,6 +88,7 @@ export function articleOf(fixture: EditorFixture, lang: Lang = fixture.lang ?? ' is_online: fixture.online?.[lang] ?? ONLINE[lang], lang, story_id: 5, + revision: REVISIONS[lang], capabilities: capsOf(fixture, lang), translations: order.map((tag) => entryOf(fixture, tag)), site_id: 1, diff --git a/web/src/views/__tests__/localizationKit.ts b/web/src/views/__tests__/localizationKit.ts index fa200de..492988d 100644 --- a/web/src/views/__tests__/localizationKit.ts +++ b/web/src/views/__tests__/localizationKit.ts @@ -7,6 +7,7 @@ import uiPlugin from '@nuxt/ui/vue-plugin' import UApp from '@nuxt/ui/components/App.vue' import router from '@/router' import { ApiError, useApi } from '@/composables/useApi' +import type { ApiErrorDetails } from '@/lib/apiError' import { useSessionStore } from '@/stores/session' import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport' import { accessFor } from '@/test/access' @@ -22,9 +23,9 @@ export type Route = unknown | ((call: Call) => unknown | Promise<unknown>) export const kit: { wrapper: VueWrapper | null; calls: Call[] } = { wrapper: null, calls: [] } -export function failWith(status: number, body?: string): Route { +export function failWith(status: number, body?: string, details?: ApiErrorDetails): Route { return () => { - throw new ApiError(status, undefined, body) + throw new ApiError(status, undefined, body, details) } } diff --git a/web/visual/__tests__/localizationScreen.spec.ts b/web/visual/__tests__/localizationScreen.spec.ts index 4d54092..3f1de51 100644 --- a/web/visual/__tests__/localizationScreen.spec.ts +++ b/web/visual/__tests__/localizationScreen.spec.ts @@ -112,6 +112,7 @@ describe('Existing screens are unaffected', () => { 'localization-list', 'localization-editor', 'localization-conflicts-moves', + 'localization-new-article', ]) }) }) diff --git a/web/visual/articleListStates.ts b/web/visual/articleListStates.ts index 2363e6e..1b9a0ba 100644 --- a/web/visual/articleListStates.ts +++ b/web/visual/articleListStates.ts @@ -48,7 +48,7 @@ function session(access: unknown): ApiMock { } } -function listApi( +export function listApi( overrides: Record<string, ApiMock> = {}, viewer: StoryViewer = {}, access: unknown = accessFor('owner'), diff --git a/web/visual/editorLocalizationStates.ts b/web/visual/editorLocalizationStates.ts index 3edd6ca..c3f4756 100644 --- a/web/visual/editorLocalizationStates.ts +++ b/web/visual/editorLocalizationStates.ts @@ -28,7 +28,7 @@ const MINUTE_MS = 60 * 1000 const SAVED_MINUTES_AGO = 3 const LOAD_SETTLE_MS = 400 -const paragraphs: Record<Lang, string[]> = { +export const paragraphs: Record<Lang, string[]> = { en: [ 'Most quotes fail because they hide the same three costs. Here is how we itemise them before the first tile is ordered.', 'Start with the room, not the catalogue. Measure the walls, note where the pipes run and photograph the existing fixtures.', @@ -84,11 +84,11 @@ const seoOneSuggestion = { suggestions: [{ code: 'cover_none', field: 'cover_image_id', params: {} }], slug: { available: true, suggestion: null }, } -const ok = (body: unknown): ApiMock => ({ status: 200, body }) -const fails = (status = 500): ApiMock => ({ status, body: 'Internal server error' }) -const hangs: ApiMock = { status: 200, hang: true } +export const ok = (body: unknown): ApiMock => ({ status: 200, body }) +export const fails = (status = 500): ApiMock => ({ status, body: 'Internal server error' }) +export const hangs: ApiMock = { status: 200, hang: true } -const site = { +export const site = { id: 1, name: 'Journal', key: 'journal', @@ -104,7 +104,8 @@ const site = { public_url_pattern: null, } -interface Setup { +export interface Setup { + search?: string current?: Lang capabilities?: string[] siblings?: Lang[] @@ -133,7 +134,7 @@ function entry(setup: Setup, lang: Lang) { } } -function articleBody(setup: Setup, lang: Lang) { +export function articleBody(setup: Setup, lang: Lang) { const version = versions[lang] const current = lang === (setup.current ?? 'fr') const siblings = setup.siblings ?? ['en', 'fr', 'de'] @@ -146,6 +147,7 @@ function articleBody(setup: Setup, lang: Lang) { is_online: setup.online?.[lang] ?? version.online, lang: version.lang, story_id: 5, + revision: 3, capabilities: current ? (setup.capabilities ?? FULL) : (setup.siblingCapabilities?.[lang] ?? FULL), @@ -196,10 +198,10 @@ function editorApi(setup: Setup = {}, overrides: Record<string, ApiMock> = {}) { } function pathOf(setup: Setup) { - return `/article/${versions[setup.current ?? 'fr'].id}/edit` + return `/article/${versions[setup.current ?? 'fr'].id}/edit${setup.search ?? ''}` } -function state( +export function state( setup: Setup, steps?: (page: Page) => Promise<void>, overrides: Record<string, ApiMock> = {}, @@ -214,13 +216,13 @@ function state( } } -const pending = (path: string): VisualState => ({ path, skip: 'pending' }) +export const pending = (path: string): VisualState => ({ path, skip: 'pending' }) -function narrow(page: Page) { +export function narrow(page: Page) { return (page.viewportSize()?.width ?? 0) < 1024 } -async function settle(page: Page) { +export async function settle(page: Page) { await page.waitForTimeout(LOAD_SETTLE_MS) await page.mouse.move(0, 0) } @@ -233,7 +235,7 @@ async function scrollEverythingToTop(page: Page) { }) } -async function editBody(page: Page) { +export async function editBody(page: Page) { await page.locator('[role="textbox"][contenteditable="true"]').first().click() await page.keyboard.press('Control+End') await page.keyboard.type(' x') @@ -270,13 +272,13 @@ async function chooseLanguage(page: Page, name: string) { await page.getByRole('menuitem', { name: new RegExp(`^${name}`) }).click() } -async function press(page: Page, name: string | RegExp) { +export async function press(page: Page, name: string | RegExp) { const exact = typeof name === 'string' await page.getByRole('button', { name, exact }).first().click() await settle(page) } -async function openOverflow(page: Page) { +export async function openOverflow(page: Page) { await page.getByRole('button', { name: 'More actions' }).first().click() await page.getByRole('menuitem').first().waitFor() await settle(page) @@ -296,7 +298,7 @@ async function pressUnpublish(page: Page) { await settle(page) } -async function confirmDialog(page: Page, label: string) { +export async function confirmDialog(page: Page, label: string) { await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click() await settle(page) } @@ -306,6 +308,13 @@ async function dirtyThenPress(page: Page, label: string | RegExp) { await press(page, label) } +async function chooseOverflowItem(page: Page, label: string) { + await openOverflow(page) + await page.getByRole('menuitem', { name: new RegExp(`^${label}`) }).click() + await page.getByRole('dialog').waitFor() + await settle(page) +} + const saveFails: Record<string, ApiMock> = { 'PATCH /v1/article/12': fails() } const savedFrench = (online = false) => ok({ ...articleBody({ current: 'fr', online: { fr: online } }, 'fr') }) @@ -535,11 +544,32 @@ const l8: Record<string, VisualState> = { await editBody(page) await pressUnpublish(page) }), - 'loc.l8.delete': pending('/article/12/edit'), + 'loc.l8.delete': state({}, (page) => chooseOverflowItem(page, 'Delete French version')), 'loc.l8.deleteSource': pending('/article/10/edit'), - 'loc.l8.deleteLast': pending('/article/10/edit'), - 'loc.l8.deleteStoryOk': pending('/article/10/edit'), - 'loc.l8.deleteStory': pending('/article/12/edit'), + 'loc.l8.deleteLast': state( + { + current: 'en', + siblings: ['en'], + online: { en: true }, + title: FRENCH_TITLE, + paragraphs: paragraphs.fr, + }, + (page) => chooseOverflowItem(page, 'Delete article'), + ), + 'loc.l8.deleteStoryOk': state( + { siblings: ['en', 'fr', 'de', 'ar'], online: { fr: true } }, + (page) => chooseOverflowItem(page, 'Delete article'), + ), + 'loc.l8.deleteStory': state( + { siblings: ['en', 'fr', 'de', 'ar'] }, + async (page) => { + await chooseOverflowItem(page, 'Delete article') + await confirmDialog(page, 'Delete article') + await page.getByRole('dialog').getByText("You can't delete this article").waitFor() + await settle(page) + }, + { 'DELETE /v1/article/10': { status: 403, body: 'missing_permission' } }, + ), 'loc.l8.done': state( {}, async (page) => { @@ -573,6 +603,135 @@ const RESTORED_AT = '2026-10-14T14:18:00Z' const STORED_DRAFT_KEY = 'renewcms:article-draft:7:12' const olderFrench = paragraphs.fr.slice(0, 2) +const THEIRS_AT = '2026-10-14T14:32:00Z' +const AGAIN_AT = '2026-10-14T14:41:00Z' +const FRENCH_SLUG = 'comment-nous-chiffrons-une-renovation' + +const conflictHeaders = { 'X-Current-Revision': '4' } +const staleSave: ApiMock = { status: 409, body: 'revision_conflict', headers: conflictHeaders } + +function frenchAtCalls(updatedAtByCall: Record<number, string>, extra: object = {}): ApiMock { + const loaded = articleBody({ suggestion: true }, 'fr') + return { + status: 200, + bodyFor: (_url, call) => + call === 1 + ? loaded + : { + ...loaded, + ...extra, + revision: 4, + updated_at: updatedAtByCall[call] ?? loaded.updated_at, + }, + } +} + +const conflictApi = (times: Record<number, string> = { 2: THEIRS_AT }) => ({ + 'PATCH /v1/article/12': staleSave, + 'GET /v1/article/12': frenchAtCalls(times), +}) + +async function openConflict(page: Page) { + await editBody(page) + await press(page, 'Save') + await page.getByRole('dialog').getByText('Someone else saved this version').first().waitFor() + await settle(page) +} + +async function storeRestoredDraft(page: Page) { + await page.evaluate( + ({ key, body, mirroredAt }) => + sessionStorage.setItem( + key, + JSON.stringify({ body, revision: 3, mirrored_at: mirroredAt }), + ), + { + key: STORED_DRAFT_KEY, + body: paragraphs.fr.map((text) => `<p>${text}</p>`).join(''), + mirroredAt: RESTORED_AT, + }, + ) + await page.reload() + await page.getByText('Restored unsaved changes from 14:18', { exact: true }).first().waitFor() + await settle(page) +} + +async function pressInDialog(page: Page, name: string) { + await page.getByRole('dialog').getByRole('button', { name, exact: true }).click() + await settle(page) +} + +function boardSite(id: number, key: string, name: string) { + return { + ...site, + id, + key, + name, + site_name: name, + is_default: id === 1, + base_url: `https://${key}.example.com`, + } +} + +const moveSites = [ + boardSite(1, 'journal', 'Journal'), + boardSite(2, 'atelier', 'Atelier'), + boardSite(4, 'archive', 'Archive'), + boardSite(3, 'notes', 'Notes'), +] + +const moveSetup: Setup = { + siblings: ['en', 'fr', 'de', 'ar'], + siblingCapabilities: { ar: READ_ONLY }, + online: { en: true, fr: false, de: true, ar: false }, +} + +const movedAll = { + story_id: 5, + site: 'atelier', + site_id: 2, + article_ids: [10, 12, 11, 13], +} + +const moveApi = (overrides: Record<string, ApiMock> = {}) => ({ + 'GET /v1/site/': ok(moveSites), + 'GET /v1/site/notes/languages': ok(languagesOf(['en'])), + 'GET /v1/site/atelier/languages': ok(languagesOf(['en', 'fr', 'de'])), + 'POST /v1/story/5/move': ok(movedAll), + ...overrides, +}) + +async function openMovePanel(page: Page) { + if (narrow(page)) { + await page.getByRole('button', { name: 'Details', exact: true }).click() + await page.getByRole('button', { name: /^Site and authors/ }).click() + await settle(page) + } + await page.getByRole('button', { name: 'Site', exact: true }).first().click() + await page.getByRole('listbox', { name: 'Site' }).waitFor() + await settle(page) +} + +async function chooseMoveTarget(page: Page, name: string) { + await openMovePanel(page) + await page.getByRole('option', { name }).click() + await page.getByRole('button', { name: 'Move article…', exact: true }).click() + await page.getByRole('dialog').getByText(`Move to ${name}?`).waitFor() + await settle(page) +} + +async function confirmMove(page: Page, name: string) { + await chooseMoveTarget(page, name) + await pressInDialog(page, 'Move article') +} + +const mv = ( + steps: (page: Page) => Promise<void>, + overrides: Record<string, ApiMock> = {}, + setup: Setup = moveSetup, + exception?: DiffException, +) => state({ suggestion: true, ...setup }, steps, moveApi(overrides), exception) + export const conflictsMovesStates: Record<string, VisualState> = { 'loc.l11.session': pending('/article/12/edit'), 'loc.l11.restored': state({ paragraphs: olderFrench }, async (page) => { @@ -589,17 +748,116 @@ export const conflictsMovesStates: Record<string, VisualState> = { await page.getByText('Restored unsaved changes from 14:18', { exact: true }).waitFor() await settle(page) }), - 'loc.conflict': pending('/article/12/edit'), - 'loc.l11.conflictRestored': pending('/article/12/edit'), - 'loc.l11.reloadConfirm': pending('/article/12/edit'), - 'loc.l11.conflict2': pending('/article/12/edit'), - 'loc.l11.download': pending('/article/12/edit'), - 'loc.l11b.moveSelect': pending('/article/12/edit'), + 'loc.conflict': state({ suggestion: true }, openConflict, conflictApi()), + 'loc.l11.conflictRestored': state( + { suggestion: true, paragraphs: olderFrench }, + async (page) => { + await storeRestoredDraft(page) + await press(page, 'Save') + await page + .getByRole('dialog') + .getByText('Someone else saved this version') + .first() + .waitFor() + await settle(page) + }, + conflictApi(), + ), + 'loc.l11.reloadConfirm': state( + { suggestion: true }, + async (page) => { + await openConflict(page) + await pressInDialog(page, 'Reload theirs') + }, + conflictApi(), + ), + 'loc.l11.conflict2': state( + { suggestion: true }, + async (page) => { + await openConflict(page) + await pressInDialog(page, 'Save over theirs') + await page.getByText("Couldn't replace theirs").first().waitFor() + await settle(page) + }, + conflictApi({ 2: THEIRS_AT, 3: AGAIN_AT }), + ), + 'loc.l11.download': state( + { suggestion: true }, + async (page) => { + await openConflict(page) + await pressInDialog(page, 'Download my version') + await page.getByText('Download started', { exact: true }).first().waitFor() + await settle(page) + }, + conflictApi(), + ), + 'loc.l11b.moveSelect': mv(openMovePanel), 'loc.l11b.moveSelectHidden': pending('/article/12/edit'), - 'loc.l11b.moveConfirm': pending('/article/12/edit'), - 'loc.l11b.moveBusy': pending('/article/12/edit'), - 'loc.l11b.moveDone': pending('/article/12/edit'), - 'loc.l11b.move': pending('/article/12/edit'), - 'loc.l11b.moveSlug': pending('/article/12/edit'), - 'loc.l11b.movePerm': pending('/article/12/edit'), + 'loc.l11b.moveConfirm': mv((page) => chooseMoveTarget(page, 'Atelier')), + 'loc.l11b.moveBusy': mv((page) => confirmMove(page, 'Atelier'), { + 'POST /v1/story/5/move': hangs, + }), + 'loc.l11b.moveDone': mv( + async (page) => { + await confirmMove(page, 'Atelier') + await page.getByText('Moved to Atelier', { exact: true }).first().waitFor() + await settle(page) + }, + { 'GET /v1/article/12': frenchAtCalls({}, { site_id: 2, revision: 4 }) }, + ), + 'loc.l11b.move': mv( + async (page) => { + await confirmMove(page, 'Notes') + await page + .getByRole('dialog') + .getByText("Notes doesn't enable every language") + .waitFor() + await settle(page) + }, + { + 'POST /v1/story/5/move': { + status: 422, + body: 'language_not_enabled', + headers: { 'X-Missing-Languages': 'fr,de,ar' }, + }, + }, + ), + 'loc.l11b.moveSlug': mv( + async (page) => { + await confirmMove(page, 'Atelier') + await page + .getByRole('dialog') + .getByText('An address is already used on Atelier') + .waitFor() + await settle(page) + }, + { + 'POST /v1/story/5/move': { + status: 409, + body: 'slug_conflict', + headers: { 'X-Conflicting-Articles': '12' }, + }, + 'GET /v1/article/12': { + status: 200, + bodyFor: (_url, call) => ({ + ...articleBody({ suggestion: true }, 'fr'), + ...(call > 1 ? { slug: FRENCH_SLUG } : {}), + }), + }, + }, + moveSetup, + { + maxPercent: { desktop: 10, mobile: 17.4 }, + reason: 'The board alert has an Edit slug button; Edit slug stays in slice 6, so the board and slice 6 are to be reconciled.', + approvedBy: 'Florian Sylvain', + }, + ), + 'loc.l11b.movePerm': mv( + async (page) => { + await confirmMove(page, 'Atelier') + await page.getByRole('dialog').getByText("You can't move this article").waitFor() + await settle(page) + }, + { 'POST /v1/story/5/move': { status: 403, body: 'missing_permission' } }, + ), } diff --git a/web/visual/newArticleLocalizationStates.ts b/web/visual/newArticleLocalizationStates.ts new file mode 100644 index 0000000..91112b3 --- /dev/null +++ b/web/visual/newArticleLocalizationStates.ts @@ -0,0 +1,325 @@ +import type { Page } from 'playwright' +import { siteLanguagesBody } from '../src/test/storyLists' +import { listApi } from './articleListStates' +import { + articleBody, + fails, + hangs, + ok, + pending, + settle, + site, + state, + type Setup, +} from './editorLocalizationStates' +import type { ApiMock, DiffException, VisualState } from './states' + +const FRENCH_TITLE_ROOF = "Liste de contrôle pour l'inspection de la toiture" +const FRENCH_TITLE_LONG = `${FRENCH_TITLE_ROOF} avec les coûts cachés derrière les carreaux, sous la baignoire et autour des canalisations anciennes` +const FRENCH_TITLE_PRICING = 'Comment nous chiffrons une rénovation de salle de bain' +const ROOF_TITLE = 'Roof inspection checklist' +const AUTHOR_NAMES = [ + 'Marta Alves', + 'Camille Roche', + 'Luis Ferrer', + 'Ana Pereira', + 'Jonas Weber', + 'Inès Duarte', + 'Paul Martin', + 'Sofia Costa', + 'Omar Haddad', +] +const COVER_IMAGE_ID = 1 +const IMAGE_COUNT = 8 + +function boardSite(id: number, key: string, name: string) { + return { + ...site, + id, + key, + name, + site_name: name, + is_default: id === 1, + base_url: `https://${key}.example.com`, + } +} + +const boardSites = [ + boardSite(1, 'journal', 'Journal'), + boardSite(2, 'atelier', 'Atelier'), + boardSite(3, 'notes', 'Notes'), + boardSite(4, 'archive', 'Archive'), +] + +function languagesOf(tags: string[]) { + const base = siteLanguagesBody('journal') + return { + ...base, + revision: `${tags.join('-')}-1`, + languages: base.languages.filter((language) => tags.includes(language.lang)), + } +} + +const modalApi = (overrides: Record<string, ApiMock> = {}) => + listApi({ + 'GET /v1/site/': ok(boardSites), + 'GET /v1/site/atelier/languages': ok(languagesOf(['en', 'fr', 'de'])), + 'GET /v1/site/notes/languages': ok(languagesOf(['en'])), + 'GET /v1/site/archive/languages': ok(languagesOf(['en'])), + ...overrides, + }) + +const roofStory = { + id: 105, + story_id: 5, + title: ROOF_TITLE, + body: '', + images: [], + is_online: true, + lang: 'en', + site_id: 1, + created_at: '2026-03-12T09:14:00Z', + updated_at: '2026-10-14T09:00:00Z', + story_updated_at: '2026-10-14T09:00:00Z', + story_authors: [], + capabilities: ['read', 'edit', 'publish', 'delete', 'create_translation'], + missing_langs: ['fr', 'ar'], + coverage: [ + { + id: 105, + lang: 'en', + title: ROOF_TITLE, + is_online: true, + language_enabled: true, + capabilities: ['read'], + }, + { + id: 106, + lang: 'de', + title: 'Checkliste für die Dachprüfung', + is_online: false, + language_enabled: true, + capabilities: ['read'], + }, + ], +} + +const missingPage: ApiMock = { + status: 200, + bodyFor: () => ({ + items: [roofStory], + total: 1, + page: 1, + size: 20, + counts: { all: 1, online: 1, draft: 0, missing: 1 }, + }), +} + +function listWithMissing(): Record<string, ApiMock> { + const base = modalApi() + const list = base['GET /v1/article/']! + return { + 'GET /v1/article/': { + status: 200, + bodyFor: (url, call) => + url.searchParams.get('tab') === 'missing' + ? missingPage.bodyFor!(url, call) + : list.bodyFor!(url, call), + }, + } +} + +async function openModal(page: Page) { + await page.getByRole('button', { name: 'New article', exact: true }).first().click() + await page.getByRole('dialog').getByText('Give the article a title').waitFor() + await settle(page) +} + +async function chooseOption(page: Page, field: string, option: string) { + await page.getByRole('combobox', { name: field, exact: true }).click() + await page.getByRole('option', { name: option, exact: true }).click() + await settle(page) +} + +async function modalTitle(page: Page, value: string) { + const input = page.getByRole('dialog').getByRole('textbox').first() + await input.fill(value) +} + +const modal = ( + steps: (page: Page) => Promise<void>, + overrides: Record<string, ApiMock> = {}, + exception?: DiffException, +): VisualState => ({ + exception, + path: '/article?site=journal&size=5', + timezone: 'UTC', + api: modalApi(overrides), + steps: async (page) => { + await openModal(page) + await steps(page) + await page.mouse.move(0, 0) + }, +}) + +async function startTranslation(page: Page) { + await page.getByRole('radio', { name: /Translation of an existing article/ }).click() + await settle(page) + await chooseOption(page, 'Language', 'French') + await page.getByLabel('Article', { exact: true }).click() + await page.getByRole('option', { name: ROOF_TITLE }).click() + await settle(page) + await page.getByRole('dialog').getByRole('textbox').first().fill(FRENCH_TITLE_ROOF) + await settle(page) +} + +const roofEditor: Setup = { + current: 'en', + siblings: ['en', 'de'], + title: ROOF_TITLE, + online: { en: true, de: false }, + search: '?dialog=add-translation&lang=fr', +} + +const pricingEditor: Setup = { + current: 'en', + siblings: ['en', 'de', 'ar'], + online: { en: true, de: true, ar: false }, + search: '?dialog=add-translation&lang=fr', +} + +async function dialogTitle(page: Page, value: string) { + const input = page.getByRole('dialog').getByRole('textbox').first() + await input.waitFor() + await input.fill(value) +} + +async function submitTranslation(page: Page) { + await page.getByRole('dialog').getByRole('button', { name: 'Create French draft' }).click() + await settle(page) +} + +const withoutImages = (setup: Setup): ApiMock => ({ + status: 200, + bodyFor: () => ({ ...articleBody(setup, 'en'), images: [] }), +}) + +const addTranslation = ( + setup: Setup, + title: string, + after?: (page: Page) => Promise<void>, + overrides: Record<string, ApiMock> = {}, + exception?: DiffException, +) => + state( + setup, + async (page) => { + await dialogTitle(page, title) + await settle(page) + await after?.(page) + await page.mouse.move(0, 0) + }, + { 'GET /v1/article/10': withoutImages(setup), ...overrides }, + exception, + ) + +const rewindDialog = async (page: Page) => { + await page.keyboard.press('Home') + await page.getByRole('dialog').evaluate((dialog) => { + dialog.querySelectorAll('*').forEach((node) => { + node.scrollTop = 0 + }) + }) +} + +const roofForm = ( + after?: (page: Page) => Promise<void>, + overrides: Record<string, ApiMock> = {}, + exception?: DiffException, +) => addTranslation(roofEditor, FRENCH_TITLE_ROOF, after, overrides, exception) + +const duplicate = (overrides: Record<string, ApiMock>) => + addTranslation(pricingEditor, FRENCH_TITLE_PRICING, submitTranslation, overrides) + +const longSource = () => { + const body = articleBody({ ...roofEditor }, 'en') + return { + status: 200, + bodyFor: () => ({ + ...body, + cover_image_id: COVER_IMAGE_ID, + images: Array.from({ length: IMAGE_COUNT }, (_, index) => ({ + id: index + 1, + url: `/images/${index + 1}.png`, + })), + authors: AUTHOR_NAMES.map((name, index) => ({ + id: index + 1, + name, + slug: name.toLowerCase().replace(' ', '-'), + })), + }), + } satisfies ApiMock +} + +const post = (key: string, response: ApiMock) => ({ + [`POST /v1/article/${key}/translations`]: response, +}) + +export const newArticleLocalizationStates: Record<string, VisualState> = { + 'loc.l3.single': modal((page) => chooseOption(page, 'Site', 'Archive')), + 'loc.l3.multi': modal(async () => undefined), + 'loc.l3.translation': modal(startTranslation, listWithMissing(), { + maxPercent: { desktop: 10, mobile: 23.2 }, + reason: 'The board draws no New article / Translation choice cards in translation mode, while the "Switching the choice keeps what is shared" scenario requires them (about 105px taller sheet); the board is to be updated.', + approvedBy: 'Florian Sylvain', + }), + 'loc.l3.sitechange': modal(async (page) => { + await modalTitle(page, 'Spring newsletter') + await chooseOption(page, 'Language', 'French') + await chooseOption(page, 'Site', 'Notes') + }), + 'loc.l4.form': roofForm(), + 'loc.l4.empty': roofForm( + async (page) => { + await page.getByRole('radio', { name: /Start empty/ }).click() + await settle(page) + }, + {}, + { + maxPercent: { desktop: 16.8, mobile: 32.2 }, + reason: 'The board still draws the Source version field and copy note in Start empty, while features/localization_translation_flows.feature says they are no longer shown; the board is to be updated.', + approvedBy: 'Florian Sylvain', + }, + ), + 'loc.l4.saving': roofForm(submitTranslation, post('10', hangs)), + 'loc.dup.readable': duplicate( + post('10', { + status: 409, + body: 'language_exists', + headers: { 'X-Existing-Article-Id': '12' }, + }), + ), + 'loc.dup.hidden': duplicate(post('10', { status: 409, body: 'language_unavailable' })), + 'loc.l4.fail': roofForm(submitTranslation, post('10', fails())), + 'loc.l4.gone': pending('/article/10/edit?dialog=add-translation&lang=fr'), + 'loc.l4.unreadable': roofForm( + submitTranslation, + post('10', { status: 404, body: 'source_unavailable' }), + { + maxPercent: { desktop: 12.1, mobile: 33.6 }, + reason: 'The board omits the Starting content block and description, while the feature requires the radios so choosing Start empty clears the warning; the board is to be updated.', + approvedBy: 'Florian Sylvain', + }, + ), + 'loc.l4.langOff': roofForm(submitTranslation, { + ...post('10', { status: 409, body: 'language_unavailable' }), + 'GET /v1/site/journal/languages': { + status: 200, + bodyFor: (_url, call) => + languagesOf(call === 1 ? ['en', 'fr', 'de', 'ar'] : ['en', 'de', 'ar']), + }, + }), + 'loc.l4.long': addTranslation(roofEditor, FRENCH_TITLE_LONG, rewindDialog, { + 'GET /v1/article/10': longSource(), + }), +} diff --git a/web/visual/states.ts b/web/visual/states.ts index bb8ace6..b160eb4 100644 --- a/web/visual/states.ts +++ b/web/visual/states.ts @@ -5,6 +5,7 @@ import { approvedExceptions } from './approvedExceptions' import { articleListStates } from './articleListStates' import { conflictsMovesStates, editorLocalizationStates } from './editorLocalizationStates' import { localizationStates } from './localizationStates' +import { newArticleLocalizationStates } from './newArticleLocalizationStates' import { rolesStates } from './rolesStates' export interface ApiMock { @@ -814,7 +815,7 @@ function submitNewArticle() { return async (page: Page) => { await openNewArticleModal(page) await fillNewArticleValid(page) - await page.getByRole('button', { name: 'Create draft', exact: true }).click() + await page.getByRole('button', { name: 'Create article', exact: true }).click() await page.mouse.move(0, 0) } } @@ -10158,7 +10159,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = { await openNewArticleModal(page) await page.getByPlaceholder(`e.g. ${newArticleTitle}`).fill('Ti') await page.getByPlaceholder(`e.g. ${newArticleTitle}`).blur() - await page.getByRole('button', { name: 'Create draft', exact: true }).click() + await page.getByRole('button', { name: 'Create article', exact: true }).click() await page.mouse.move(0, 0) }, }, @@ -10350,4 +10351,5 @@ export const visualStates: Record<string, Record<string, VisualState>> = { 'localization-list': articleListStates, 'localization-editor': editorLocalizationStates, 'localization-conflicts-moves': conflictsMovesStates, + 'localization-new-article': newArticleLocalizationStates, }